From 88f5d3a9593e26e2b75f82fdf3e96e8b09e073ec Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Thu, 12 Feb 2026 19:21:59 +0100 Subject: [PATCH 1/2] feat: i18n --- l10n.yaml | 6 + lib/app.dart | 12 + lib/config/router.dart | 3 +- lib/helpers/l10n_helpers.dart | 44 + lib/l10n/app_en.arb | 432 +++++ lib/l10n/app_es.arb | 257 +++ lib/l10n/app_localizations.dart | 1495 +++++++++++++++++ lib/l10n/app_localizations_en.dart | 786 +++++++++ lib/l10n/app_localizations_es.dart | 794 +++++++++ lib/providers/locale_provider.dart | 49 + lib/providers/locale_provider.g.dart | 29 + lib/screens/browse/feed_screen.dart | 24 +- .../browse/library_browser_screen.dart | 31 +- lib/screens/library/library_screen.dart | 46 +- .../publication_detail_screen.dart | 54 +- lib/screens/reader/epub_reader_screen.dart | 75 +- lib/screens/reader/reader_screen.dart | 53 +- lib/screens/servers/add_server_screen.dart | 57 +- lib/screens/servers/edit_server_screen.dart | 61 +- lib/screens/servers/server_list_screen.dart | 36 +- lib/screens/settings/about_screen.dart | 16 +- .../settings/advanced_settings_screen.dart | 39 +- .../settings/appearance_settings_screen.dart | 16 +- lib/screens/settings/dev_tests_screen.dart | 25 +- .../filter_quality_settings_screen.dart | 60 +- .../settings/language_settings_screen.dart | 93 + .../precache_pages_settings_screen.dart | 80 +- .../settings/readers_settings_screen.dart | 56 +- .../reading_mode_settings_screen.dart | 50 +- lib/screens/settings/settings_screen.dart | 38 +- lib/screens/shell/main_shell_screen.dart | 23 +- lib/widgets/next_in_series_overlay.dart | 8 +- lib/widgets/publication_card.dart | 7 +- lib/widgets/recently_read_card.dart | 65 +- lib/widgets/server_card.dart | 34 +- lib/widgets/worldhopper_app_bar.dart | 3 +- pubspec.lock | 13 + pubspec.yaml | 4 + 38 files changed, 4534 insertions(+), 440 deletions(-) create mode 100644 l10n.yaml create mode 100644 lib/helpers/l10n_helpers.dart create mode 100644 lib/l10n/app_en.arb create mode 100644 lib/l10n/app_es.arb create mode 100644 lib/l10n/app_localizations.dart create mode 100644 lib/l10n/app_localizations_en.dart create mode 100644 lib/l10n/app_localizations_es.dart create mode 100644 lib/providers/locale_provider.dart create mode 100644 lib/providers/locale_provider.g.dart create mode 100644 lib/screens/settings/language_settings_screen.dart diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..82684d9 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,6 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations +nullable-getter: false +output-dir: lib/l10n diff --git a/lib/app.dart b/lib/app.dart index ebb1a04..c52dc3b 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/config/router.dart'; import 'package:worldhopper/config/theme.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/providers/theme_provider.dart'; +import 'package:worldhopper/providers/locale_provider.dart'; /// Root application widget class App extends ConsumerWidget { @@ -12,12 +15,21 @@ class App extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final themeMode = ref.watch(themeNotifierProvider); + final locale = ref.watch(localeNotifierProvider); return MaterialApp.router( title: AppConstants.appName, theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, themeMode: themeMode, + locale: locale, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, routerConfig: AppRouter.router, debugShowCheckedModeBanner: false, ); diff --git a/lib/config/router.dart b/lib/config/router.dart index 628dccf..8ee5850 100644 --- a/lib/config/router.dart +++ b/lib/config/router.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:go_router/go_router.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/screens/servers/server_list_screen.dart'; @@ -140,7 +141,7 @@ class AppRouter { ], errorBuilder: (context, state) => Scaffold( body: Center( - child: Text('Page not found: ${state.uri}'), + child: Text(AppLocalizations.of(context).routeNotFound(state.uri.toString())), ), ), ); diff --git a/lib/helpers/l10n_helpers.dart b/lib/helpers/l10n_helpers.dart new file mode 100644 index 0000000..0091689 --- /dev/null +++ b/lib/helpers/l10n_helpers.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; +import 'package:worldhopper/providers/reading_mode_provider.dart'; + +/// Returns the full label for a reading mode +String readingModeLabel(AppLocalizations l10n, ReadingMode mode) { + return switch (mode) { + ReadingMode.ltr => l10n.readingModeLtr, + ReadingMode.rtl => l10n.readingModeRtl, + ReadingMode.verticalContinuous => l10n.readingModeVertical, + }; +} + +/// Returns the short label for a reading mode (used in reader settings menu) +String readingModeShortLabel(AppLocalizations l10n, ReadingMode mode) { + return switch (mode) { + ReadingMode.ltr => l10n.readingModeShortLtr, + ReadingMode.rtl => l10n.readingModeShortRtl, + ReadingMode.verticalContinuous => l10n.readingModeShortVertical, + }; +} + +/// Returns the label for a filter quality setting +String filterQualityLabel(AppLocalizations l10n, FilterQuality quality) { + return switch (quality) { + FilterQuality.none => l10n.filterQualityNone, + FilterQuality.low => l10n.filterQualityLow, + FilterQuality.medium => l10n.filterQualityMedium, + FilterQuality.high => l10n.filterQualityHigh, + }; +} + +/// Returns the label for a precache pages setting +String precachePagesLabel(AppLocalizations l10n, int count) { + return switch (count) { + 0 => l10n.precachePagesOff, + 1 => l10n.precachePages1, + 2 => l10n.precachePages2, + 3 => l10n.precachePages3, + 4 => l10n.precachePages4, + 5 => l10n.precachePages5, + _ => '$count pages', + }; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..2911e65 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,432 @@ +{ + "@@locale": "en", + + "appName": "Worldhopper", + + "navLibrary": "Library", + "navServers": "Servers", + "navSettings": "Settings", + + "settingsTitle": "Settings", + "settingsAppearance": "Appearance", + "settingsAppearanceSubtitle": "Display options", + "settingsLanguage": "Language", + "settingsLanguageSubtitle": "App language", + "settingsReaders": "Readers", + "settingsReadersSubtitle": "Reader options", + "settingsAdvanced": "Advanced", + "settingsAdvancedSubtitle": "Developer and debugging", + "settingsAbout": "About", + "settingsAboutSubtitle": "Worldhopper", + + "appearanceTitle": "Appearance", + "appearanceTheme": "Theme", + "themeLight": "Light", + "themeDark": "Dark", + "themeSystem": "System", + "themeSystemSubtitle": "Follow device theme", + + "languageTitle": "Language", + "languageSystem": "System default", + "languageSystemSubtitle": "Use device language", + + "readersTitle": "Readers", + "readersImageReaderSection": "Image Reader", + "readersReadingDirection": "Reading direction", + "readersFilterQuality": "Filter quality", + "readersPrecachePages": "Pre-cache pages", + "readersTwoPageSpread": "Two-page spread", + "readersTwoPageSpreadSubtitle": "Show two pages side by side on wide screens", + "readersFirstPageIsCover": "First page is cover", + "readersFirstPageIsCoverSubtitle": "Show the first page alone in two-page spread", + + "readingDirectionTitle": "Reading Direction", + "readingModeLtr": "Left to right", + "readingModeLtrDescription": "Standard Western reading order", + "readingModeRtl": "Right to left", + "readingModeRtlDescription": "Manga-style reading order", + "readingModeVertical": "Vertical scroll", + "readingModeVerticalDescription": "Continuous webtoon-style scrolling", + "readingDirectionHelp": "Controls the reading direction for the image reader. Left to right is standard for Western comics, right to left for manga, and vertical scroll for webtoons.", + "readingModeShortLtr": "LTR", + "readingModeShortRtl": "RTL", + "readingModeShortVertical": "Vertical", + + "filterQualityTitle": "Filter Quality", + "filterQualityNone": "None", + "filterQualityNoneDescription": "Fastest, may look pixelated", + "filterQualityLow": "Low", + "filterQualityLowDescription": "Bilinear interpolation", + "filterQualityMedium": "Medium", + "filterQualityMediumDescription": "Bilinear with mipmaps, good balance", + "filterQualityHigh": "High", + "filterQualityHighDescription": "Bicubic interpolation, best quality", + "filterQualityHelp": "Controls how images are scaled when a page is zoomed out to fit the screen. Higher quality makes text and fine details sharper but uses more GPU resources. Medium is recommended for most devices.", + + "precachePagesTitle": "Pre-cache Pages", + "precachePagesOff": "Off", + "precachePagesOffDescription": "Pages load on demand", + "precachePages1": "1 page", + "precachePages1Description": "Minimal pre-loading", + "precachePages2": "2 pages", + "precachePages2Description": "Light pre-loading", + "precachePages3": "3 pages", + "precachePages3Description": "Balanced, recommended", + "precachePages4": "4 pages", + "precachePages4Description": "Aggressive pre-loading", + "precachePages5": "5 pages", + "precachePages5Description": "Maximum pre-loading, uses more bandwidth", + "precachePagesHelp": "Controls how many upcoming pages are pre-loaded in the background while reading. Higher values make swiping feel more seamless but use more bandwidth and memory.", + + "advancedTitle": "Advanced", + "advancedDeveloperMode": "Developer mode", + "advancedDeveloperModeSubtitle": "Enable debugging tools", + "advancedTests": "Tests", + "advancedTestsSubtitle": "Preview UI components", + "advancedDebuggingInfo": "Debugging info", + "advancedVersion": "Version", + "advancedBuildNumber": "Build number", + "advancedPackageName": "Package name", + "advancedDatabaseVersion": "Database version", + "advancedPlatform": "Platform", + "advancedCopyToClipboard": "Copy to clipboard", + "advancedCopyToClipboardSubtitle": "Copy all debug info for bug reports", + "advancedDebugInfoCopied": "Debug info copied to clipboard", + "advancedFailedToLoadInfo": "Failed to load info", + + "aboutTitle": "About", + "aboutVersion": "Version", + "aboutVersionUnknown": "unknown", + "aboutSourceCode": "Source code", + "aboutReportIssue": "Report an issue", + "aboutReportIssueSubtitle": "Open an issue on the repository", + + "devTestsTitle": "Dev Tests", + "devTestsSnackBars": "Snack Bars", + "devTestsSuccess": "Success", + "devTestsError": "Error", + "devTestsWarning": "Warning", + "devTestsInfo": "Info", + "devTestsSuccessMessage": "This is a success message", + "devTestsErrorMessage": "This is an error message", + "devTestsWarningMessage": "This is a warning message", + "devTestsInfoMessage": "This is an info message", + + "serverListTitle": "My Servers", + "serverListEmpty": "No Servers Yet", + "serverListEmptySubtitle": "Add your first OPDS server to start browsing and reading", + "serverListOffline": "You are offline. Connect to the internet to browse servers.", + "serverListErrorLoading": "Error loading servers: {error}", + "@serverListErrorLoading": { + "placeholders": { + "error": { "type": "String" } + } + }, + "serverListRetry": "Retry", + "serverListAddServer": "Add Server", + "serverDeleteTitle": "Delete Server", + "serverDeleteConfirmation": "Are you sure you want to delete \"{name}\"?", + "@serverDeleteConfirmation": { + "placeholders": { + "name": { "type": "String" } + } + }, + "serverDeleted": "{name} deleted", + "@serverDeleted": { + "placeholders": { + "name": { "type": "String" } + } + }, + + "addServerTitle": "Add Server", + "addServerNameLabel": "Server Name", + "addServerNameHint": "My Library", + "addServerUrlLabel": "Server URL", + "addServerUrlHint": "https://example.com/opds", + "addServerRequiresAuth": "Requires Authentication", + "addServerRequiresAuthSubtitle": "Enable if server requires login", + "addServerUsername": "Username", + "addServerPassword": "Password", + "addServerValidationNameRequired": "Please enter a server name", + "addServerValidationUrlRequired": "Please enter a server URL", + "addServerValidationUrlInvalid": "Please enter a valid URL", + "addServerValidationUrlScheme": "URL must start with http:// or https://", + "addServerValidationUsernameRequired": "Please enter a username", + "addServerValidationPasswordRequired": "Please enter a password", + "addServerHelp": "Enter the root URL of your OPDS server. Credentials will be securely stored on your device.", + "addServerButton": "Add Server", + "addServerDuplicate": "A server with this URL already exists", + "addServerSuccess": "{name} added successfully", + "@addServerSuccess": { + "placeholders": { + "name": { "type": "String" } + } + }, + "addServerError": "Error adding server: {error}", + "@addServerError": { + "placeholders": { + "error": { "type": "String" } + } + }, + + "editServerTitle": "Edit Server", + "editServerNotFound": "Server not found", + "editServerInfo": "Server Information", + "editServerAdded": "Added", + "editServerLastSynced": "Last Synced", + "editServerSaveButton": "Save Changes", + "editServerSuccess": "{name} updated successfully", + "@editServerSuccess": { + "placeholders": { + "name": { "type": "String" } + } + }, + "editServerError": "Error updating server: {error}", + "@editServerError": { + "placeholders": { + "error": { "type": "String" } + } + }, + "editServerErrorLoading": "Error loading server: {error}", + "@editServerErrorLoading": { + "placeholders": { + "error": { "type": "String" } + } + }, + + "readerError": "Error", + "readerNoStreamLink": "This publication cannot be read (no stream link)", + "readerServerNotFound": "Server not found", + "readerErrorLoadingServer": "Error loading server: {error}", + "@readerErrorLoadingServer": { + "placeholders": { + "error": { "type": "String" } + } + }, + "readerReadingDirectionWithMode": "Reading direction ({mode})", + "@readerReadingDirectionWithMode": { + "placeholders": { + "mode": { "type": "String" } + } + }, + "readerPageOf": "Page {current} of {total}", + "@readerPageOf": { + "placeholders": { + "current": { "type": "int" }, + "total": { "type": "int" } + } + }, + "readerPagesOf": "Pages {start}-{end} of {total}", + "@readerPagesOf": { + "placeholders": { + "start": { "type": "int" }, + "end": { "type": "int" }, + "total": { "type": "int" } + } + }, + + "epubPreparing": "Preparing...", + "epubLoadingFromCache": "Loading from cache...", + "epubDownloading": "Downloading... {percent}%", + "@epubDownloading": { + "placeholders": { + "percent": { "type": "int" } + } + }, + "epubProcessing": "Processing EPUB...", + "epubOpeningReader": "Opening reader...", + "epubErrorLoading": "Error loading EPUB: {error}", + "@epubErrorLoading": { + "placeholders": { + "error": { "type": "String" } + } + }, + "epubGoBack": "Go Back", + "epubSearch": "Search", + "epubSearchHint": "Enter search term...", + "epubNoResults": "No results found", + "epubSearchResults": "{count} results for \"{query}\"", + "@epubSearchResults": { + "placeholders": { + "count": { "type": "int" }, + "query": { "type": "String" } + } + }, + "epubSearchFailed": "Search failed: {error}", + "@epubSearchFailed": { + "placeholders": { + "error": { "type": "String" } + } + }, + "epubChapters": "Chapters", + "epubBack": "Back", + + "libraryTitle": "Library", + "libraryRecentlyRead": "Recently Read", + "libraryNoServers": "No Servers Configured", + "libraryNoServersSubtitle": "Add an OPDS server to start reading books", + "libraryAddServer": "Add Server", + "libraryNoHistory": "No Reading History", + "libraryNoHistorySubtitle": "Start reading a book to see it here", + "libraryBrowseServers": "Browse Servers", + "libraryErrorLoading": "Error Loading Library", + + "browserLoading": "Loading...", + "browserLibrary": "Library", + "browserNoContent": "No Content Available", + "browserLibraryEmpty": "This library appears to be empty", + "browserCollectionEmpty": "This collection appears to be empty", + "browserFailedToLoadLibrary": "Failed to Load Library", + "browserFailedToLoadFeed": "Failed to Load Feed", + "browserRetry": "Retry", + "browserFailedToLoadMore": "Failed to load more: {error}", + "@browserFailedToLoadMore": { + "placeholders": { + "error": { "type": "String" } + } + }, + "browserOfflineEpubNotCached": "This EPUB is not cached. You may not be able to read it offline.", + "browserOfflinePagesWarning": "You are offline. Pages may not load properly.", + + "publicationPages": "{count} pages", + "@publicationPages": { + "placeholders": { + "count": { "type": "int" } + } + }, + "publicationReadingProgress": "Reading Progress", + "publicationPageOfTotal": "Page {current} of {total}", + "@publicationPageOfTotal": { + "placeholders": { + "current": { "type": "int" }, + "total": { "type": "int" } + } + }, + "publicationLastRead": "Last read: {time}", + "@publicationLastRead": { + "placeholders": { + "time": { "type": "String" } + } + }, + "publicationCannotRead": "This publication cannot be read in the app", + "publicationContinueFromPage": "Continue reading from page {page}", + "@publicationContinueFromPage": { + "placeholders": { + "page": { "type": "int" } + } + }, + "publicationStartReading": "Start Reading", + "publicationContinueReading": "Continue Reading", + "publicationReadAgain": "Read Again", + "publicationStartFromBeginning": "Start from Beginning", + "publicationCollection": "Collection", + + "recentlyReadPublicationNotFound": "Publication not found", + "recentlyReadErrorLoading": "Error loading publication", + "recentlyReadViewRawData": "View raw data", + "recentlyReadRemove": "Remove from Recently Read", + "recentlyReadRemoveTitle": "Remove from Recently Read", + "recentlyReadRemoveConfirmation": "Remove \"{title}\" from your reading history?\n\nThis will not delete the book from your library.", + "@recentlyReadRemoveConfirmation": { + "placeholders": { + "title": { "type": "String" } + } + }, + "recentlyReadRemoved": "Removed from Recently Read", + "recentlyReadRemoveFailed": "Failed to remove: {error}", + "@recentlyReadRemoveFailed": { + "placeholders": { + "error": { "type": "String" } + } + }, + "recentlyReadRawData": "Raw Data", + "recentlyReadOfflineEpubNotCached": "This EPUB is not cached. Connect to internet to download.", + "recentlyReadOfflinePagesWarning": "You are offline. Pages may not load.", + "recentlyReadUnsupportedFormat": "Unsupported format", + + "serverCardEdit": "Edit", + "serverCardDelete": "Delete", + "serverCardAuthenticated": "Authenticated", + "serverCardLastSynced": "Last synced: {time}", + "@serverCardLastSynced": { + "placeholders": { + "time": { "type": "String" } + } + }, + + "offlineSemanticLabel": "Offline", + + "nextInSeriesNext": "Next", + "nextInSeriesFinished": "Finished", + "nextInSeriesBackToDetails": "Back to details", + "nextInSeriesBackToLibrary": "Back to library", + + "routeNotFound": "Page not found: {uri}", + "@routeNotFound": { + "placeholders": { + "uri": { "type": "String" } + } + }, + + "commonCancel": "Cancel", + "commonDelete": "Delete", + "commonRemove": "Remove", + + "timeJustNow": "just now", + "timeMinutesAgo": "{minutes}m ago", + "@timeMinutesAgo": { + "placeholders": { + "minutes": { "type": "int" } + } + }, + "timeHoursAgo": "{hours}h ago", + "@timeHoursAgo": { + "placeholders": { + "hours": { "type": "int" } + } + }, + "timeDaysAgo": "{days}d ago", + "@timeDaysAgo": { + "placeholders": { + "days": { "type": "int" } + } + }, + "timeWeeksAgo": "{weeks}w ago", + "@timeWeeksAgo": { + "placeholders": { + "weeks": { "type": "int" } + } + }, + "timeMonthsAgo": "{months}mo ago", + "@timeMonthsAgo": { + "placeholders": { + "months": { "type": "int" } + } + }, + "timeYearsAgo": "{years}y ago", + "@timeYearsAgo": { + "placeholders": { + "years": { "type": "int" } + } + }, + "timeToday": "Today", + "timeYesterday": "Yesterday", + "timeMinAgo": "{minutes} min ago", + "@timeMinAgo": { + "placeholders": { + "minutes": { "type": "int" } + } + }, + "timeHoursAgoLong": "{count} {count, plural, =1{hour} other{hours}} ago", + "@timeHoursAgoLong": { + "placeholders": { + "count": { "type": "int" } + } + }, + "timeDaysAgoLong": "{days} days ago", + "@timeDaysAgoLong": { + "placeholders": { + "days": { "type": "int" } + } + } +} diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb new file mode 100644 index 0000000..353eac3 --- /dev/null +++ b/lib/l10n/app_es.arb @@ -0,0 +1,257 @@ +{ + "@@locale": "es", + + "appName": "Worldhopper", + + "navLibrary": "Biblioteca", + "navServers": "Servidores", + "navSettings": "Ajustes", + + "settingsTitle": "Ajustes", + "settingsAppearance": "Apariencia", + "settingsAppearanceSubtitle": "Opciones de visualización", + "settingsLanguage": "Idioma", + "settingsLanguageSubtitle": "Idioma de la aplicación", + "settingsReaders": "Lectores", + "settingsReadersSubtitle": "Opciones de lectura", + "settingsAdvanced": "Avanzado", + "settingsAdvancedSubtitle": "Desarrollo y depuración", + "settingsAbout": "Acerca de", + "settingsAboutSubtitle": "Worldhopper", + + "appearanceTitle": "Apariencia", + "appearanceTheme": "Tema", + "themeLight": "Claro", + "themeDark": "Oscuro", + "themeSystem": "Sistema", + "themeSystemSubtitle": "Seguir el tema del dispositivo", + + "languageTitle": "Idioma", + "languageSystem": "Predeterminado del sistema", + "languageSystemSubtitle": "Usar el idioma del dispositivo", + + "readersTitle": "Lectores", + "readersImageReaderSection": "Lector de imágenes", + "readersReadingDirection": "Dirección de lectura", + "readersFilterQuality": "Calidad de filtro", + "readersPrecachePages": "Precarga de páginas", + "readersTwoPageSpread": "Doble página", + "readersTwoPageSpreadSubtitle": "Mostrar dos páginas lado a lado en pantallas anchas", + "readersFirstPageIsCover": "La primera página es portada", + "readersFirstPageIsCoverSubtitle": "Mostrar la primera página sola en doble página", + + "readingDirectionTitle": "Dirección de lectura", + "readingModeLtr": "Izquierda a derecha", + "readingModeLtrDescription": "Orden de lectura occidental estándar", + "readingModeRtl": "Derecha a izquierda", + "readingModeRtlDescription": "Orden de lectura estilo manga", + "readingModeVertical": "Desplazamiento vertical", + "readingModeVerticalDescription": "Desplazamiento continuo estilo webtoon", + "readingDirectionHelp": "Controla la dirección de lectura del lector de imágenes. Izquierda a derecha es el estándar para cómics occidentales, derecha a izquierda para manga, y desplazamiento vertical para webtoons.", + "readingModeShortLtr": "IaD", + "readingModeShortRtl": "DaI", + "readingModeShortVertical": "Vertical", + + "filterQualityTitle": "Calidad de filtro", + "filterQualityNone": "Ninguna", + "filterQualityNoneDescription": "Más rápido, puede verse pixelado", + "filterQualityLow": "Baja", + "filterQualityLowDescription": "Interpolación bilineal", + "filterQualityMedium": "Media", + "filterQualityMediumDescription": "Bilineal con mipmaps, buen equilibrio", + "filterQualityHigh": "Alta", + "filterQualityHighDescription": "Interpolación bicúbica, mejor calidad", + "filterQualityHelp": "Controla cómo se escalan las imágenes cuando una página se reduce para ajustarse a la pantalla. Mayor calidad hace que el texto y los detalles finos sean más nítidos, pero usa más recursos de GPU. Se recomienda Media para la mayoría de dispositivos.", + + "precachePagesTitle": "Precarga de páginas", + "precachePagesOff": "Desactivada", + "precachePagesOffDescription": "Las páginas se cargan bajo demanda", + "precachePages1": "1 página", + "precachePages1Description": "Precarga mínima", + "precachePages2": "2 páginas", + "precachePages2Description": "Precarga ligera", + "precachePages3": "3 páginas", + "precachePages3Description": "Equilibrada, recomendada", + "precachePages4": "4 páginas", + "precachePages4Description": "Precarga agresiva", + "precachePages5": "5 páginas", + "precachePages5Description": "Precarga máxima, usa más ancho de banda", + "precachePagesHelp": "Controla cuántas páginas siguientes se precargan en segundo plano mientras lees. Valores más altos hacen que pasar página sea más fluido, pero usan más ancho de banda y memoria.", + + "advancedTitle": "Avanzado", + "advancedDeveloperMode": "Modo desarrollador", + "advancedDeveloperModeSubtitle": "Activar herramientas de depuración", + "advancedTests": "Pruebas", + "advancedTestsSubtitle": "Previsualizar componentes de interfaz", + "advancedDebuggingInfo": "Información de depuración", + "advancedVersion": "Versión", + "advancedBuildNumber": "Número de compilación", + "advancedPackageName": "Nombre del paquete", + "advancedDatabaseVersion": "Versión de la base de datos", + "advancedPlatform": "Plataforma", + "advancedCopyToClipboard": "Copiar al portapapeles", + "advancedCopyToClipboardSubtitle": "Copiar toda la información de depuración para informes de errores", + "advancedDebugInfoCopied": "Información de depuración copiada al portapapeles", + "advancedFailedToLoadInfo": "Error al cargar la información", + + "aboutTitle": "Acerca de", + "aboutVersion": "Versión", + "aboutVersionUnknown": "desconocida", + "aboutSourceCode": "Código fuente", + "aboutReportIssue": "Reportar un problema", + "aboutReportIssueSubtitle": "Abrir un informe en el repositorio", + + "devTestsTitle": "Pruebas de desarrollo", + "devTestsSnackBars": "Barras de notificación", + "devTestsSuccess": "Éxito", + "devTestsError": "Error", + "devTestsWarning": "Advertencia", + "devTestsInfo": "Información", + "devTestsSuccessMessage": "Este es un mensaje de éxito", + "devTestsErrorMessage": "Este es un mensaje de error", + "devTestsWarningMessage": "Este es un mensaje de advertencia", + "devTestsInfoMessage": "Este es un mensaje informativo", + + "serverListTitle": "Mis servidores", + "serverListEmpty": "Sin servidores", + "serverListEmptySubtitle": "Añade tu primer servidor OPDS para empezar a explorar y leer", + "serverListOffline": "No hay conexión. Conéctate a internet para explorar servidores.", + "serverListErrorLoading": "Error al cargar servidores: {error}", + "serverListRetry": "Reintentar", + "serverListAddServer": "Añadir servidor", + "serverDeleteTitle": "Eliminar servidor", + "serverDeleteConfirmation": "¿Seguro que quieres eliminar \"{name}\"?", + "serverDeleted": "{name} eliminado", + + "addServerTitle": "Añadir servidor", + "addServerNameLabel": "Nombre del servidor", + "addServerNameHint": "Mi biblioteca", + "addServerUrlLabel": "URL del servidor", + "addServerUrlHint": "https://ejemplo.com/opds", + "addServerRequiresAuth": "Requiere autenticación", + "addServerRequiresAuthSubtitle": "Activar si el servidor requiere inicio de sesión", + "addServerUsername": "Usuario", + "addServerPassword": "Contraseña", + "addServerValidationNameRequired": "Introduce un nombre de servidor", + "addServerValidationUrlRequired": "Introduce una URL de servidor", + "addServerValidationUrlInvalid": "Introduce una URL válida", + "addServerValidationUrlScheme": "La URL debe empezar con http:// o https://", + "addServerValidationUsernameRequired": "Introduce un nombre de usuario", + "addServerValidationPasswordRequired": "Introduce una contraseña", + "addServerHelp": "Introduce la URL raíz de tu servidor OPDS. Las credenciales se almacenarán de forma segura en tu dispositivo.", + "addServerButton": "Añadir servidor", + "addServerDuplicate": "Ya existe un servidor con esta URL", + "addServerSuccess": "{name} añadido correctamente", + "addServerError": "Error al añadir servidor: {error}", + + "editServerTitle": "Editar servidor", + "editServerNotFound": "Servidor no encontrado", + "editServerInfo": "Información del servidor", + "editServerAdded": "Añadido", + "editServerLastSynced": "Última sincronización", + "editServerSaveButton": "Guardar cambios", + "editServerSuccess": "{name} actualizado correctamente", + "editServerError": "Error al actualizar servidor: {error}", + "editServerErrorLoading": "Error al cargar servidor: {error}", + + "readerError": "Error", + "readerNoStreamLink": "Esta publicación no se puede leer (sin enlace de transmisión)", + "readerServerNotFound": "Servidor no encontrado", + "readerErrorLoadingServer": "Error al cargar servidor: {error}", + "readerReadingDirectionWithMode": "Dirección de lectura ({mode})", + "readerPageOf": "Página {current} de {total}", + "readerPagesOf": "Páginas {start}-{end} de {total}", + + "epubPreparing": "Preparando...", + "epubLoadingFromCache": "Cargando desde caché...", + "epubDownloading": "Descargando... {percent}%", + "epubProcessing": "Procesando EPUB...", + "epubOpeningReader": "Abriendo lector...", + "epubErrorLoading": "Error al cargar EPUB: {error}", + "epubGoBack": "Volver", + "epubSearch": "Buscar", + "epubSearchHint": "Introduce un término de búsqueda...", + "epubNoResults": "No se encontraron resultados", + "epubSearchResults": "{count} resultados para \"{query}\"", + "epubSearchFailed": "Error en la búsqueda: {error}", + "epubChapters": "Capítulos", + "epubBack": "Atrás", + + "libraryTitle": "Biblioteca", + "libraryRecentlyRead": "Leídos recientemente", + "libraryNoServers": "Sin servidores configurados", + "libraryNoServersSubtitle": "Añade un servidor OPDS para empezar a leer libros", + "libraryAddServer": "Añadir servidor", + "libraryNoHistory": "Sin historial de lectura", + "libraryNoHistorySubtitle": "Empieza a leer un libro para verlo aquí", + "libraryBrowseServers": "Explorar servidores", + "libraryErrorLoading": "Error al cargar la biblioteca", + + "browserLoading": "Cargando...", + "browserLibrary": "Biblioteca", + "browserNoContent": "Sin contenido disponible", + "browserLibraryEmpty": "Esta biblioteca parece estar vacía", + "browserCollectionEmpty": "Esta colección parece estar vacía", + "browserFailedToLoadLibrary": "Error al cargar la biblioteca", + "browserFailedToLoadFeed": "Error al cargar el feed", + "browserRetry": "Reintentar", + "browserFailedToLoadMore": "Error al cargar más: {error}", + "browserOfflineEpubNotCached": "Este EPUB no está en caché. Es posible que no puedas leerlo sin conexión.", + "browserOfflinePagesWarning": "No hay conexión. Las páginas podrían no cargarse correctamente.", + + "publicationPages": "{count} páginas", + "publicationReadingProgress": "Progreso de lectura", + "publicationPageOfTotal": "Página {current} de {total}", + "publicationLastRead": "Última lectura: {time}", + "publicationCannotRead": "Esta publicación no se puede leer en la aplicación", + "publicationContinueFromPage": "Continuar leyendo desde la página {page}", + "publicationStartReading": "Empezar a leer", + "publicationContinueReading": "Continuar leyendo", + "publicationReadAgain": "Leer de nuevo", + "publicationStartFromBeginning": "Empezar desde el principio", + "publicationCollection": "Colección", + + "recentlyReadPublicationNotFound": "Publicación no encontrada", + "recentlyReadErrorLoading": "Error al cargar la publicación", + "recentlyReadViewRawData": "Ver datos sin procesar", + "recentlyReadRemove": "Eliminar de leídos recientes", + "recentlyReadRemoveTitle": "Eliminar de leídos recientes", + "recentlyReadRemoveConfirmation": "¿Eliminar \"{title}\" de tu historial de lectura?\n\nEsto no eliminará el libro de tu biblioteca.", + "recentlyReadRemoved": "Eliminado de leídos recientes", + "recentlyReadRemoveFailed": "Error al eliminar: {error}", + "recentlyReadRawData": "Datos sin procesar", + "recentlyReadOfflineEpubNotCached": "Este EPUB no está en caché. Conéctate a internet para descargarlo.", + "recentlyReadOfflinePagesWarning": "No hay conexión. Las páginas podrían no cargarse.", + "recentlyReadUnsupportedFormat": "Formato no compatible", + + "serverCardEdit": "Editar", + "serverCardDelete": "Eliminar", + "serverCardAuthenticated": "Autenticado", + "serverCardLastSynced": "Última sincronización: {time}", + + "offlineSemanticLabel": "Sin conexión", + + "nextInSeriesNext": "Siguiente", + "nextInSeriesFinished": "Terminado", + "nextInSeriesBackToDetails": "Volver a detalles", + "nextInSeriesBackToLibrary": "Volver a la biblioteca", + + "routeNotFound": "Página no encontrada: {uri}", + + "commonCancel": "Cancelar", + "commonDelete": "Eliminar", + "commonRemove": "Eliminar", + + "timeJustNow": "ahora mismo", + "timeMinutesAgo": "hace {minutes} min", + "timeHoursAgo": "hace {hours} h", + "timeDaysAgo": "hace {days} d", + "timeWeeksAgo": "hace {weeks} sem", + "timeMonthsAgo": "hace {months} mes(es)", + "timeYearsAgo": "hace {years} año(s)", + "timeToday": "Hoy", + "timeYesterday": "Ayer", + "timeMinAgo": "hace {minutes} min", + "timeHoursAgoLong": "hace {count} {count, plural, =1{hora} other{horas}}", + "timeDaysAgoLong": "hace {days} días" +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..ac52f44 --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,1495 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_es.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations of(BuildContext context) { + return Localizations.of(context, AppLocalizations)!; + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('es') + ]; + + /// No description provided for @appName. + /// + /// In en, this message translates to: + /// **'Worldhopper'** + String get appName; + + /// No description provided for @navLibrary. + /// + /// In en, this message translates to: + /// **'Library'** + String get navLibrary; + + /// No description provided for @navServers. + /// + /// In en, this message translates to: + /// **'Servers'** + String get navServers; + + /// No description provided for @navSettings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get navSettings; + + /// No description provided for @settingsTitle. + /// + /// In en, this message translates to: + /// **'Settings'** + String get settingsTitle; + + /// No description provided for @settingsAppearance. + /// + /// In en, this message translates to: + /// **'Appearance'** + String get settingsAppearance; + + /// No description provided for @settingsAppearanceSubtitle. + /// + /// In en, this message translates to: + /// **'Display options'** + String get settingsAppearanceSubtitle; + + /// No description provided for @settingsLanguage. + /// + /// In en, this message translates to: + /// **'Language'** + String get settingsLanguage; + + /// No description provided for @settingsLanguageSubtitle. + /// + /// In en, this message translates to: + /// **'App language'** + String get settingsLanguageSubtitle; + + /// No description provided for @settingsReaders. + /// + /// In en, this message translates to: + /// **'Readers'** + String get settingsReaders; + + /// No description provided for @settingsReadersSubtitle. + /// + /// In en, this message translates to: + /// **'Reader options'** + String get settingsReadersSubtitle; + + /// No description provided for @settingsAdvanced. + /// + /// In en, this message translates to: + /// **'Advanced'** + String get settingsAdvanced; + + /// No description provided for @settingsAdvancedSubtitle. + /// + /// In en, this message translates to: + /// **'Developer and debugging'** + String get settingsAdvancedSubtitle; + + /// No description provided for @settingsAbout. + /// + /// In en, this message translates to: + /// **'About'** + String get settingsAbout; + + /// No description provided for @settingsAboutSubtitle. + /// + /// In en, this message translates to: + /// **'Worldhopper'** + String get settingsAboutSubtitle; + + /// No description provided for @appearanceTitle. + /// + /// In en, this message translates to: + /// **'Appearance'** + String get appearanceTitle; + + /// No description provided for @appearanceTheme. + /// + /// In en, this message translates to: + /// **'Theme'** + String get appearanceTheme; + + /// No description provided for @themeLight. + /// + /// In en, this message translates to: + /// **'Light'** + String get themeLight; + + /// No description provided for @themeDark. + /// + /// In en, this message translates to: + /// **'Dark'** + String get themeDark; + + /// No description provided for @themeSystem. + /// + /// In en, this message translates to: + /// **'System'** + String get themeSystem; + + /// No description provided for @themeSystemSubtitle. + /// + /// In en, this message translates to: + /// **'Follow device theme'** + String get themeSystemSubtitle; + + /// No description provided for @languageTitle. + /// + /// In en, this message translates to: + /// **'Language'** + String get languageTitle; + + /// No description provided for @languageSystem. + /// + /// In en, this message translates to: + /// **'System default'** + String get languageSystem; + + /// No description provided for @languageSystemSubtitle. + /// + /// In en, this message translates to: + /// **'Use device language'** + String get languageSystemSubtitle; + + /// No description provided for @readersTitle. + /// + /// In en, this message translates to: + /// **'Readers'** + String get readersTitle; + + /// No description provided for @readersImageReaderSection. + /// + /// In en, this message translates to: + /// **'Image Reader'** + String get readersImageReaderSection; + + /// No description provided for @readersReadingDirection. + /// + /// In en, this message translates to: + /// **'Reading direction'** + String get readersReadingDirection; + + /// No description provided for @readersFilterQuality. + /// + /// In en, this message translates to: + /// **'Filter quality'** + String get readersFilterQuality; + + /// No description provided for @readersPrecachePages. + /// + /// In en, this message translates to: + /// **'Pre-cache pages'** + String get readersPrecachePages; + + /// No description provided for @readersTwoPageSpread. + /// + /// In en, this message translates to: + /// **'Two-page spread'** + String get readersTwoPageSpread; + + /// No description provided for @readersTwoPageSpreadSubtitle. + /// + /// In en, this message translates to: + /// **'Show two pages side by side on wide screens'** + String get readersTwoPageSpreadSubtitle; + + /// No description provided for @readersFirstPageIsCover. + /// + /// In en, this message translates to: + /// **'First page is cover'** + String get readersFirstPageIsCover; + + /// No description provided for @readersFirstPageIsCoverSubtitle. + /// + /// In en, this message translates to: + /// **'Show the first page alone in two-page spread'** + String get readersFirstPageIsCoverSubtitle; + + /// No description provided for @readingDirectionTitle. + /// + /// In en, this message translates to: + /// **'Reading Direction'** + String get readingDirectionTitle; + + /// No description provided for @readingModeLtr. + /// + /// In en, this message translates to: + /// **'Left to right'** + String get readingModeLtr; + + /// No description provided for @readingModeLtrDescription. + /// + /// In en, this message translates to: + /// **'Standard Western reading order'** + String get readingModeLtrDescription; + + /// No description provided for @readingModeRtl. + /// + /// In en, this message translates to: + /// **'Right to left'** + String get readingModeRtl; + + /// No description provided for @readingModeRtlDescription. + /// + /// In en, this message translates to: + /// **'Manga-style reading order'** + String get readingModeRtlDescription; + + /// No description provided for @readingModeVertical. + /// + /// In en, this message translates to: + /// **'Vertical scroll'** + String get readingModeVertical; + + /// No description provided for @readingModeVerticalDescription. + /// + /// In en, this message translates to: + /// **'Continuous webtoon-style scrolling'** + String get readingModeVerticalDescription; + + /// No description provided for @readingDirectionHelp. + /// + /// In en, this message translates to: + /// **'Controls the reading direction for the image reader. Left to right is standard for Western comics, right to left for manga, and vertical scroll for webtoons.'** + String get readingDirectionHelp; + + /// No description provided for @readingModeShortLtr. + /// + /// In en, this message translates to: + /// **'LTR'** + String get readingModeShortLtr; + + /// No description provided for @readingModeShortRtl. + /// + /// In en, this message translates to: + /// **'RTL'** + String get readingModeShortRtl; + + /// No description provided for @readingModeShortVertical. + /// + /// In en, this message translates to: + /// **'Vertical'** + String get readingModeShortVertical; + + /// No description provided for @filterQualityTitle. + /// + /// In en, this message translates to: + /// **'Filter Quality'** + String get filterQualityTitle; + + /// No description provided for @filterQualityNone. + /// + /// In en, this message translates to: + /// **'None'** + String get filterQualityNone; + + /// No description provided for @filterQualityNoneDescription. + /// + /// In en, this message translates to: + /// **'Fastest, may look pixelated'** + String get filterQualityNoneDescription; + + /// No description provided for @filterQualityLow. + /// + /// In en, this message translates to: + /// **'Low'** + String get filterQualityLow; + + /// No description provided for @filterQualityLowDescription. + /// + /// In en, this message translates to: + /// **'Bilinear interpolation'** + String get filterQualityLowDescription; + + /// No description provided for @filterQualityMedium. + /// + /// In en, this message translates to: + /// **'Medium'** + String get filterQualityMedium; + + /// No description provided for @filterQualityMediumDescription. + /// + /// In en, this message translates to: + /// **'Bilinear with mipmaps, good balance'** + String get filterQualityMediumDescription; + + /// No description provided for @filterQualityHigh. + /// + /// In en, this message translates to: + /// **'High'** + String get filterQualityHigh; + + /// No description provided for @filterQualityHighDescription. + /// + /// In en, this message translates to: + /// **'Bicubic interpolation, best quality'** + String get filterQualityHighDescription; + + /// No description provided for @filterQualityHelp. + /// + /// In en, this message translates to: + /// **'Controls how images are scaled when a page is zoomed out to fit the screen. Higher quality makes text and fine details sharper but uses more GPU resources. Medium is recommended for most devices.'** + String get filterQualityHelp; + + /// No description provided for @precachePagesTitle. + /// + /// In en, this message translates to: + /// **'Pre-cache Pages'** + String get precachePagesTitle; + + /// No description provided for @precachePagesOff. + /// + /// In en, this message translates to: + /// **'Off'** + String get precachePagesOff; + + /// No description provided for @precachePagesOffDescription. + /// + /// In en, this message translates to: + /// **'Pages load on demand'** + String get precachePagesOffDescription; + + /// No description provided for @precachePages1. + /// + /// In en, this message translates to: + /// **'1 page'** + String get precachePages1; + + /// No description provided for @precachePages1Description. + /// + /// In en, this message translates to: + /// **'Minimal pre-loading'** + String get precachePages1Description; + + /// No description provided for @precachePages2. + /// + /// In en, this message translates to: + /// **'2 pages'** + String get precachePages2; + + /// No description provided for @precachePages2Description. + /// + /// In en, this message translates to: + /// **'Light pre-loading'** + String get precachePages2Description; + + /// No description provided for @precachePages3. + /// + /// In en, this message translates to: + /// **'3 pages'** + String get precachePages3; + + /// No description provided for @precachePages3Description. + /// + /// In en, this message translates to: + /// **'Balanced, recommended'** + String get precachePages3Description; + + /// No description provided for @precachePages4. + /// + /// In en, this message translates to: + /// **'4 pages'** + String get precachePages4; + + /// No description provided for @precachePages4Description. + /// + /// In en, this message translates to: + /// **'Aggressive pre-loading'** + String get precachePages4Description; + + /// No description provided for @precachePages5. + /// + /// In en, this message translates to: + /// **'5 pages'** + String get precachePages5; + + /// No description provided for @precachePages5Description. + /// + /// In en, this message translates to: + /// **'Maximum pre-loading, uses more bandwidth'** + String get precachePages5Description; + + /// No description provided for @precachePagesHelp. + /// + /// In en, this message translates to: + /// **'Controls how many upcoming pages are pre-loaded in the background while reading. Higher values make swiping feel more seamless but use more bandwidth and memory.'** + String get precachePagesHelp; + + /// No description provided for @advancedTitle. + /// + /// In en, this message translates to: + /// **'Advanced'** + String get advancedTitle; + + /// No description provided for @advancedDeveloperMode. + /// + /// In en, this message translates to: + /// **'Developer mode'** + String get advancedDeveloperMode; + + /// No description provided for @advancedDeveloperModeSubtitle. + /// + /// In en, this message translates to: + /// **'Enable debugging tools'** + String get advancedDeveloperModeSubtitle; + + /// No description provided for @advancedTests. + /// + /// In en, this message translates to: + /// **'Tests'** + String get advancedTests; + + /// No description provided for @advancedTestsSubtitle. + /// + /// In en, this message translates to: + /// **'Preview UI components'** + String get advancedTestsSubtitle; + + /// No description provided for @advancedDebuggingInfo. + /// + /// In en, this message translates to: + /// **'Debugging info'** + String get advancedDebuggingInfo; + + /// No description provided for @advancedVersion. + /// + /// In en, this message translates to: + /// **'Version'** + String get advancedVersion; + + /// No description provided for @advancedBuildNumber. + /// + /// In en, this message translates to: + /// **'Build number'** + String get advancedBuildNumber; + + /// No description provided for @advancedPackageName. + /// + /// In en, this message translates to: + /// **'Package name'** + String get advancedPackageName; + + /// No description provided for @advancedDatabaseVersion. + /// + /// In en, this message translates to: + /// **'Database version'** + String get advancedDatabaseVersion; + + /// No description provided for @advancedPlatform. + /// + /// In en, this message translates to: + /// **'Platform'** + String get advancedPlatform; + + /// No description provided for @advancedCopyToClipboard. + /// + /// In en, this message translates to: + /// **'Copy to clipboard'** + String get advancedCopyToClipboard; + + /// No description provided for @advancedCopyToClipboardSubtitle. + /// + /// In en, this message translates to: + /// **'Copy all debug info for bug reports'** + String get advancedCopyToClipboardSubtitle; + + /// No description provided for @advancedDebugInfoCopied. + /// + /// In en, this message translates to: + /// **'Debug info copied to clipboard'** + String get advancedDebugInfoCopied; + + /// No description provided for @advancedFailedToLoadInfo. + /// + /// In en, this message translates to: + /// **'Failed to load info'** + String get advancedFailedToLoadInfo; + + /// No description provided for @aboutTitle. + /// + /// In en, this message translates to: + /// **'About'** + String get aboutTitle; + + /// No description provided for @aboutVersion. + /// + /// In en, this message translates to: + /// **'Version'** + String get aboutVersion; + + /// No description provided for @aboutVersionUnknown. + /// + /// In en, this message translates to: + /// **'unknown'** + String get aboutVersionUnknown; + + /// No description provided for @aboutSourceCode. + /// + /// In en, this message translates to: + /// **'Source code'** + String get aboutSourceCode; + + /// No description provided for @aboutReportIssue. + /// + /// In en, this message translates to: + /// **'Report an issue'** + String get aboutReportIssue; + + /// No description provided for @aboutReportIssueSubtitle. + /// + /// In en, this message translates to: + /// **'Open an issue on the repository'** + String get aboutReportIssueSubtitle; + + /// No description provided for @devTestsTitle. + /// + /// In en, this message translates to: + /// **'Dev Tests'** + String get devTestsTitle; + + /// No description provided for @devTestsSnackBars. + /// + /// In en, this message translates to: + /// **'Snack Bars'** + String get devTestsSnackBars; + + /// No description provided for @devTestsSuccess. + /// + /// In en, this message translates to: + /// **'Success'** + String get devTestsSuccess; + + /// No description provided for @devTestsError. + /// + /// In en, this message translates to: + /// **'Error'** + String get devTestsError; + + /// No description provided for @devTestsWarning. + /// + /// In en, this message translates to: + /// **'Warning'** + String get devTestsWarning; + + /// No description provided for @devTestsInfo. + /// + /// In en, this message translates to: + /// **'Info'** + String get devTestsInfo; + + /// No description provided for @devTestsSuccessMessage. + /// + /// In en, this message translates to: + /// **'This is a success message'** + String get devTestsSuccessMessage; + + /// No description provided for @devTestsErrorMessage. + /// + /// In en, this message translates to: + /// **'This is an error message'** + String get devTestsErrorMessage; + + /// No description provided for @devTestsWarningMessage. + /// + /// In en, this message translates to: + /// **'This is a warning message'** + String get devTestsWarningMessage; + + /// No description provided for @devTestsInfoMessage. + /// + /// In en, this message translates to: + /// **'This is an info message'** + String get devTestsInfoMessage; + + /// No description provided for @serverListTitle. + /// + /// In en, this message translates to: + /// **'My Servers'** + String get serverListTitle; + + /// No description provided for @serverListEmpty. + /// + /// In en, this message translates to: + /// **'No Servers Yet'** + String get serverListEmpty; + + /// No description provided for @serverListEmptySubtitle. + /// + /// In en, this message translates to: + /// **'Add your first OPDS server to start browsing and reading'** + String get serverListEmptySubtitle; + + /// No description provided for @serverListOffline. + /// + /// In en, this message translates to: + /// **'You are offline. Connect to the internet to browse servers.'** + String get serverListOffline; + + /// No description provided for @serverListErrorLoading. + /// + /// In en, this message translates to: + /// **'Error loading servers: {error}'** + String serverListErrorLoading(String error); + + /// No description provided for @serverListRetry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get serverListRetry; + + /// No description provided for @serverListAddServer. + /// + /// In en, this message translates to: + /// **'Add Server'** + String get serverListAddServer; + + /// No description provided for @serverDeleteTitle. + /// + /// In en, this message translates to: + /// **'Delete Server'** + String get serverDeleteTitle; + + /// No description provided for @serverDeleteConfirmation. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete \"{name}\"?'** + String serverDeleteConfirmation(String name); + + /// No description provided for @serverDeleted. + /// + /// In en, this message translates to: + /// **'{name} deleted'** + String serverDeleted(String name); + + /// No description provided for @addServerTitle. + /// + /// In en, this message translates to: + /// **'Add Server'** + String get addServerTitle; + + /// No description provided for @addServerNameLabel. + /// + /// In en, this message translates to: + /// **'Server Name'** + String get addServerNameLabel; + + /// No description provided for @addServerNameHint. + /// + /// In en, this message translates to: + /// **'My Library'** + String get addServerNameHint; + + /// No description provided for @addServerUrlLabel. + /// + /// In en, this message translates to: + /// **'Server URL'** + String get addServerUrlLabel; + + /// No description provided for @addServerUrlHint. + /// + /// In en, this message translates to: + /// **'https://example.com/opds'** + String get addServerUrlHint; + + /// No description provided for @addServerRequiresAuth. + /// + /// In en, this message translates to: + /// **'Requires Authentication'** + String get addServerRequiresAuth; + + /// No description provided for @addServerRequiresAuthSubtitle. + /// + /// In en, this message translates to: + /// **'Enable if server requires login'** + String get addServerRequiresAuthSubtitle; + + /// No description provided for @addServerUsername. + /// + /// In en, this message translates to: + /// **'Username'** + String get addServerUsername; + + /// No description provided for @addServerPassword. + /// + /// In en, this message translates to: + /// **'Password'** + String get addServerPassword; + + /// No description provided for @addServerValidationNameRequired. + /// + /// In en, this message translates to: + /// **'Please enter a server name'** + String get addServerValidationNameRequired; + + /// No description provided for @addServerValidationUrlRequired. + /// + /// In en, this message translates to: + /// **'Please enter a server URL'** + String get addServerValidationUrlRequired; + + /// No description provided for @addServerValidationUrlInvalid. + /// + /// In en, this message translates to: + /// **'Please enter a valid URL'** + String get addServerValidationUrlInvalid; + + /// No description provided for @addServerValidationUrlScheme. + /// + /// In en, this message translates to: + /// **'URL must start with http:// or https://'** + String get addServerValidationUrlScheme; + + /// No description provided for @addServerValidationUsernameRequired. + /// + /// In en, this message translates to: + /// **'Please enter a username'** + String get addServerValidationUsernameRequired; + + /// No description provided for @addServerValidationPasswordRequired. + /// + /// In en, this message translates to: + /// **'Please enter a password'** + String get addServerValidationPasswordRequired; + + /// No description provided for @addServerHelp. + /// + /// In en, this message translates to: + /// **'Enter the root URL of your OPDS server. Credentials will be securely stored on your device.'** + String get addServerHelp; + + /// No description provided for @addServerButton. + /// + /// In en, this message translates to: + /// **'Add Server'** + String get addServerButton; + + /// No description provided for @addServerDuplicate. + /// + /// In en, this message translates to: + /// **'A server with this URL already exists'** + String get addServerDuplicate; + + /// No description provided for @addServerSuccess. + /// + /// In en, this message translates to: + /// **'{name} added successfully'** + String addServerSuccess(String name); + + /// No description provided for @addServerError. + /// + /// In en, this message translates to: + /// **'Error adding server: {error}'** + String addServerError(String error); + + /// No description provided for @editServerTitle. + /// + /// In en, this message translates to: + /// **'Edit Server'** + String get editServerTitle; + + /// No description provided for @editServerNotFound. + /// + /// In en, this message translates to: + /// **'Server not found'** + String get editServerNotFound; + + /// No description provided for @editServerInfo. + /// + /// In en, this message translates to: + /// **'Server Information'** + String get editServerInfo; + + /// No description provided for @editServerAdded. + /// + /// In en, this message translates to: + /// **'Added'** + String get editServerAdded; + + /// No description provided for @editServerLastSynced. + /// + /// In en, this message translates to: + /// **'Last Synced'** + String get editServerLastSynced; + + /// No description provided for @editServerSaveButton. + /// + /// In en, this message translates to: + /// **'Save Changes'** + String get editServerSaveButton; + + /// No description provided for @editServerSuccess. + /// + /// In en, this message translates to: + /// **'{name} updated successfully'** + String editServerSuccess(String name); + + /// No description provided for @editServerError. + /// + /// In en, this message translates to: + /// **'Error updating server: {error}'** + String editServerError(String error); + + /// No description provided for @editServerErrorLoading. + /// + /// In en, this message translates to: + /// **'Error loading server: {error}'** + String editServerErrorLoading(String error); + + /// No description provided for @readerError. + /// + /// In en, this message translates to: + /// **'Error'** + String get readerError; + + /// No description provided for @readerNoStreamLink. + /// + /// In en, this message translates to: + /// **'This publication cannot be read (no stream link)'** + String get readerNoStreamLink; + + /// No description provided for @readerServerNotFound. + /// + /// In en, this message translates to: + /// **'Server not found'** + String get readerServerNotFound; + + /// No description provided for @readerErrorLoadingServer. + /// + /// In en, this message translates to: + /// **'Error loading server: {error}'** + String readerErrorLoadingServer(String error); + + /// No description provided for @readerReadingDirectionWithMode. + /// + /// In en, this message translates to: + /// **'Reading direction ({mode})'** + String readerReadingDirectionWithMode(String mode); + + /// No description provided for @readerPageOf. + /// + /// In en, this message translates to: + /// **'Page {current} of {total}'** + String readerPageOf(int current, int total); + + /// No description provided for @readerPagesOf. + /// + /// In en, this message translates to: + /// **'Pages {start}-{end} of {total}'** + String readerPagesOf(int start, int end, int total); + + /// No description provided for @epubPreparing. + /// + /// In en, this message translates to: + /// **'Preparing...'** + String get epubPreparing; + + /// No description provided for @epubLoadingFromCache. + /// + /// In en, this message translates to: + /// **'Loading from cache...'** + String get epubLoadingFromCache; + + /// No description provided for @epubDownloading. + /// + /// In en, this message translates to: + /// **'Downloading... {percent}%'** + String epubDownloading(int percent); + + /// No description provided for @epubProcessing. + /// + /// In en, this message translates to: + /// **'Processing EPUB...'** + String get epubProcessing; + + /// No description provided for @epubOpeningReader. + /// + /// In en, this message translates to: + /// **'Opening reader...'** + String get epubOpeningReader; + + /// No description provided for @epubErrorLoading. + /// + /// In en, this message translates to: + /// **'Error loading EPUB: {error}'** + String epubErrorLoading(String error); + + /// No description provided for @epubGoBack. + /// + /// In en, this message translates to: + /// **'Go Back'** + String get epubGoBack; + + /// No description provided for @epubSearch. + /// + /// In en, this message translates to: + /// **'Search'** + String get epubSearch; + + /// No description provided for @epubSearchHint. + /// + /// In en, this message translates to: + /// **'Enter search term...'** + String get epubSearchHint; + + /// No description provided for @epubNoResults. + /// + /// In en, this message translates to: + /// **'No results found'** + String get epubNoResults; + + /// No description provided for @epubSearchResults. + /// + /// In en, this message translates to: + /// **'{count} results for \"{query}\"'** + String epubSearchResults(int count, String query); + + /// No description provided for @epubSearchFailed. + /// + /// In en, this message translates to: + /// **'Search failed: {error}'** + String epubSearchFailed(String error); + + /// No description provided for @epubChapters. + /// + /// In en, this message translates to: + /// **'Chapters'** + String get epubChapters; + + /// No description provided for @epubBack. + /// + /// In en, this message translates to: + /// **'Back'** + String get epubBack; + + /// No description provided for @libraryTitle. + /// + /// In en, this message translates to: + /// **'Library'** + String get libraryTitle; + + /// No description provided for @libraryRecentlyRead. + /// + /// In en, this message translates to: + /// **'Recently Read'** + String get libraryRecentlyRead; + + /// No description provided for @libraryNoServers. + /// + /// In en, this message translates to: + /// **'No Servers Configured'** + String get libraryNoServers; + + /// No description provided for @libraryNoServersSubtitle. + /// + /// In en, this message translates to: + /// **'Add an OPDS server to start reading books'** + String get libraryNoServersSubtitle; + + /// No description provided for @libraryAddServer. + /// + /// In en, this message translates to: + /// **'Add Server'** + String get libraryAddServer; + + /// No description provided for @libraryNoHistory. + /// + /// In en, this message translates to: + /// **'No Reading History'** + String get libraryNoHistory; + + /// No description provided for @libraryNoHistorySubtitle. + /// + /// In en, this message translates to: + /// **'Start reading a book to see it here'** + String get libraryNoHistorySubtitle; + + /// No description provided for @libraryBrowseServers. + /// + /// In en, this message translates to: + /// **'Browse Servers'** + String get libraryBrowseServers; + + /// No description provided for @libraryErrorLoading. + /// + /// In en, this message translates to: + /// **'Error Loading Library'** + String get libraryErrorLoading; + + /// No description provided for @browserLoading. + /// + /// In en, this message translates to: + /// **'Loading...'** + String get browserLoading; + + /// No description provided for @browserLibrary. + /// + /// In en, this message translates to: + /// **'Library'** + String get browserLibrary; + + /// No description provided for @browserNoContent. + /// + /// In en, this message translates to: + /// **'No Content Available'** + String get browserNoContent; + + /// No description provided for @browserLibraryEmpty. + /// + /// In en, this message translates to: + /// **'This library appears to be empty'** + String get browserLibraryEmpty; + + /// No description provided for @browserCollectionEmpty. + /// + /// In en, this message translates to: + /// **'This collection appears to be empty'** + String get browserCollectionEmpty; + + /// No description provided for @browserFailedToLoadLibrary. + /// + /// In en, this message translates to: + /// **'Failed to Load Library'** + String get browserFailedToLoadLibrary; + + /// No description provided for @browserFailedToLoadFeed. + /// + /// In en, this message translates to: + /// **'Failed to Load Feed'** + String get browserFailedToLoadFeed; + + /// No description provided for @browserRetry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get browserRetry; + + /// No description provided for @browserFailedToLoadMore. + /// + /// In en, this message translates to: + /// **'Failed to load more: {error}'** + String browserFailedToLoadMore(String error); + + /// No description provided for @browserOfflineEpubNotCached. + /// + /// In en, this message translates to: + /// **'This EPUB is not cached. You may not be able to read it offline.'** + String get browserOfflineEpubNotCached; + + /// No description provided for @browserOfflinePagesWarning. + /// + /// In en, this message translates to: + /// **'You are offline. Pages may not load properly.'** + String get browserOfflinePagesWarning; + + /// No description provided for @publicationPages. + /// + /// In en, this message translates to: + /// **'{count} pages'** + String publicationPages(int count); + + /// No description provided for @publicationReadingProgress. + /// + /// In en, this message translates to: + /// **'Reading Progress'** + String get publicationReadingProgress; + + /// No description provided for @publicationPageOfTotal. + /// + /// In en, this message translates to: + /// **'Page {current} of {total}'** + String publicationPageOfTotal(int current, int total); + + /// No description provided for @publicationLastRead. + /// + /// In en, this message translates to: + /// **'Last read: {time}'** + String publicationLastRead(String time); + + /// No description provided for @publicationCannotRead. + /// + /// In en, this message translates to: + /// **'This publication cannot be read in the app'** + String get publicationCannotRead; + + /// No description provided for @publicationContinueFromPage. + /// + /// In en, this message translates to: + /// **'Continue reading from page {page}'** + String publicationContinueFromPage(int page); + + /// No description provided for @publicationStartReading. + /// + /// In en, this message translates to: + /// **'Start Reading'** + String get publicationStartReading; + + /// No description provided for @publicationContinueReading. + /// + /// In en, this message translates to: + /// **'Continue Reading'** + String get publicationContinueReading; + + /// No description provided for @publicationReadAgain. + /// + /// In en, this message translates to: + /// **'Read Again'** + String get publicationReadAgain; + + /// No description provided for @publicationStartFromBeginning. + /// + /// In en, this message translates to: + /// **'Start from Beginning'** + String get publicationStartFromBeginning; + + /// No description provided for @publicationCollection. + /// + /// In en, this message translates to: + /// **'Collection'** + String get publicationCollection; + + /// No description provided for @recentlyReadPublicationNotFound. + /// + /// In en, this message translates to: + /// **'Publication not found'** + String get recentlyReadPublicationNotFound; + + /// No description provided for @recentlyReadErrorLoading. + /// + /// In en, this message translates to: + /// **'Error loading publication'** + String get recentlyReadErrorLoading; + + /// No description provided for @recentlyReadViewRawData. + /// + /// In en, this message translates to: + /// **'View raw data'** + String get recentlyReadViewRawData; + + /// No description provided for @recentlyReadRemove. + /// + /// In en, this message translates to: + /// **'Remove from Recently Read'** + String get recentlyReadRemove; + + /// No description provided for @recentlyReadRemoveTitle. + /// + /// In en, this message translates to: + /// **'Remove from Recently Read'** + String get recentlyReadRemoveTitle; + + /// No description provided for @recentlyReadRemoveConfirmation. + /// + /// In en, this message translates to: + /// **'Remove \"{title}\" from your reading history?\n\nThis will not delete the book from your library.'** + String recentlyReadRemoveConfirmation(String title); + + /// No description provided for @recentlyReadRemoved. + /// + /// In en, this message translates to: + /// **'Removed from Recently Read'** + String get recentlyReadRemoved; + + /// No description provided for @recentlyReadRemoveFailed. + /// + /// In en, this message translates to: + /// **'Failed to remove: {error}'** + String recentlyReadRemoveFailed(String error); + + /// No description provided for @recentlyReadRawData. + /// + /// In en, this message translates to: + /// **'Raw Data'** + String get recentlyReadRawData; + + /// No description provided for @recentlyReadOfflineEpubNotCached. + /// + /// In en, this message translates to: + /// **'This EPUB is not cached. Connect to internet to download.'** + String get recentlyReadOfflineEpubNotCached; + + /// No description provided for @recentlyReadOfflinePagesWarning. + /// + /// In en, this message translates to: + /// **'You are offline. Pages may not load.'** + String get recentlyReadOfflinePagesWarning; + + /// No description provided for @recentlyReadUnsupportedFormat. + /// + /// In en, this message translates to: + /// **'Unsupported format'** + String get recentlyReadUnsupportedFormat; + + /// No description provided for @serverCardEdit. + /// + /// In en, this message translates to: + /// **'Edit'** + String get serverCardEdit; + + /// No description provided for @serverCardDelete. + /// + /// In en, this message translates to: + /// **'Delete'** + String get serverCardDelete; + + /// No description provided for @serverCardAuthenticated. + /// + /// In en, this message translates to: + /// **'Authenticated'** + String get serverCardAuthenticated; + + /// No description provided for @serverCardLastSynced. + /// + /// In en, this message translates to: + /// **'Last synced: {time}'** + String serverCardLastSynced(String time); + + /// No description provided for @offlineSemanticLabel. + /// + /// In en, this message translates to: + /// **'Offline'** + String get offlineSemanticLabel; + + /// No description provided for @nextInSeriesNext. + /// + /// In en, this message translates to: + /// **'Next'** + String get nextInSeriesNext; + + /// No description provided for @nextInSeriesFinished. + /// + /// In en, this message translates to: + /// **'Finished'** + String get nextInSeriesFinished; + + /// No description provided for @nextInSeriesBackToDetails. + /// + /// In en, this message translates to: + /// **'Back to details'** + String get nextInSeriesBackToDetails; + + /// No description provided for @nextInSeriesBackToLibrary. + /// + /// In en, this message translates to: + /// **'Back to library'** + String get nextInSeriesBackToLibrary; + + /// No description provided for @routeNotFound. + /// + /// In en, this message translates to: + /// **'Page not found: {uri}'** + String routeNotFound(String uri); + + /// No description provided for @commonCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get commonCancel; + + /// No description provided for @commonDelete. + /// + /// In en, this message translates to: + /// **'Delete'** + String get commonDelete; + + /// No description provided for @commonRemove. + /// + /// In en, this message translates to: + /// **'Remove'** + String get commonRemove; + + /// No description provided for @timeJustNow. + /// + /// In en, this message translates to: + /// **'just now'** + String get timeJustNow; + + /// No description provided for @timeMinutesAgo. + /// + /// In en, this message translates to: + /// **'{minutes}m ago'** + String timeMinutesAgo(int minutes); + + /// No description provided for @timeHoursAgo. + /// + /// In en, this message translates to: + /// **'{hours}h ago'** + String timeHoursAgo(int hours); + + /// No description provided for @timeDaysAgo. + /// + /// In en, this message translates to: + /// **'{days}d ago'** + String timeDaysAgo(int days); + + /// No description provided for @timeWeeksAgo. + /// + /// In en, this message translates to: + /// **'{weeks}w ago'** + String timeWeeksAgo(int weeks); + + /// No description provided for @timeMonthsAgo. + /// + /// In en, this message translates to: + /// **'{months}mo ago'** + String timeMonthsAgo(int months); + + /// No description provided for @timeYearsAgo. + /// + /// In en, this message translates to: + /// **'{years}y ago'** + String timeYearsAgo(int years); + + /// No description provided for @timeToday. + /// + /// In en, this message translates to: + /// **'Today'** + String get timeToday; + + /// No description provided for @timeYesterday. + /// + /// In en, this message translates to: + /// **'Yesterday'** + String get timeYesterday; + + /// No description provided for @timeMinAgo. + /// + /// In en, this message translates to: + /// **'{minutes} min ago'** + String timeMinAgo(int minutes); + + /// No description provided for @timeHoursAgoLong. + /// + /// In en, this message translates to: + /// **'{count} {count, plural, =1{hour} other{hours}} ago'** + String timeHoursAgoLong(int count); + + /// No description provided for @timeDaysAgoLong. + /// + /// In en, this message translates to: + /// **'{days} days ago'** + String timeDaysAgoLong(int days); +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'es'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'es': + return AppLocalizationsEs(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.'); +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..f63413f --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,786 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appName => 'Worldhopper'; + + @override + String get navLibrary => 'Library'; + + @override + String get navServers => 'Servers'; + + @override + String get navSettings => 'Settings'; + + @override + String get settingsTitle => 'Settings'; + + @override + String get settingsAppearance => 'Appearance'; + + @override + String get settingsAppearanceSubtitle => 'Display options'; + + @override + String get settingsLanguage => 'Language'; + + @override + String get settingsLanguageSubtitle => 'App language'; + + @override + String get settingsReaders => 'Readers'; + + @override + String get settingsReadersSubtitle => 'Reader options'; + + @override + String get settingsAdvanced => 'Advanced'; + + @override + String get settingsAdvancedSubtitle => 'Developer and debugging'; + + @override + String get settingsAbout => 'About'; + + @override + String get settingsAboutSubtitle => 'Worldhopper'; + + @override + String get appearanceTitle => 'Appearance'; + + @override + String get appearanceTheme => 'Theme'; + + @override + String get themeLight => 'Light'; + + @override + String get themeDark => 'Dark'; + + @override + String get themeSystem => 'System'; + + @override + String get themeSystemSubtitle => 'Follow device theme'; + + @override + String get languageTitle => 'Language'; + + @override + String get languageSystem => 'System default'; + + @override + String get languageSystemSubtitle => 'Use device language'; + + @override + String get readersTitle => 'Readers'; + + @override + String get readersImageReaderSection => 'Image Reader'; + + @override + String get readersReadingDirection => 'Reading direction'; + + @override + String get readersFilterQuality => 'Filter quality'; + + @override + String get readersPrecachePages => 'Pre-cache pages'; + + @override + String get readersTwoPageSpread => 'Two-page spread'; + + @override + String get readersTwoPageSpreadSubtitle => + 'Show two pages side by side on wide screens'; + + @override + String get readersFirstPageIsCover => 'First page is cover'; + + @override + String get readersFirstPageIsCoverSubtitle => + 'Show the first page alone in two-page spread'; + + @override + String get readingDirectionTitle => 'Reading Direction'; + + @override + String get readingModeLtr => 'Left to right'; + + @override + String get readingModeLtrDescription => 'Standard Western reading order'; + + @override + String get readingModeRtl => 'Right to left'; + + @override + String get readingModeRtlDescription => 'Manga-style reading order'; + + @override + String get readingModeVertical => 'Vertical scroll'; + + @override + String get readingModeVerticalDescription => + 'Continuous webtoon-style scrolling'; + + @override + String get readingDirectionHelp => + 'Controls the reading direction for the image reader. Left to right is standard for Western comics, right to left for manga, and vertical scroll for webtoons.'; + + @override + String get readingModeShortLtr => 'LTR'; + + @override + String get readingModeShortRtl => 'RTL'; + + @override + String get readingModeShortVertical => 'Vertical'; + + @override + String get filterQualityTitle => 'Filter Quality'; + + @override + String get filterQualityNone => 'None'; + + @override + String get filterQualityNoneDescription => 'Fastest, may look pixelated'; + + @override + String get filterQualityLow => 'Low'; + + @override + String get filterQualityLowDescription => 'Bilinear interpolation'; + + @override + String get filterQualityMedium => 'Medium'; + + @override + String get filterQualityMediumDescription => + 'Bilinear with mipmaps, good balance'; + + @override + String get filterQualityHigh => 'High'; + + @override + String get filterQualityHighDescription => + 'Bicubic interpolation, best quality'; + + @override + String get filterQualityHelp => + 'Controls how images are scaled when a page is zoomed out to fit the screen. Higher quality makes text and fine details sharper but uses more GPU resources. Medium is recommended for most devices.'; + + @override + String get precachePagesTitle => 'Pre-cache Pages'; + + @override + String get precachePagesOff => 'Off'; + + @override + String get precachePagesOffDescription => 'Pages load on demand'; + + @override + String get precachePages1 => '1 page'; + + @override + String get precachePages1Description => 'Minimal pre-loading'; + + @override + String get precachePages2 => '2 pages'; + + @override + String get precachePages2Description => 'Light pre-loading'; + + @override + String get precachePages3 => '3 pages'; + + @override + String get precachePages3Description => 'Balanced, recommended'; + + @override + String get precachePages4 => '4 pages'; + + @override + String get precachePages4Description => 'Aggressive pre-loading'; + + @override + String get precachePages5 => '5 pages'; + + @override + String get precachePages5Description => + 'Maximum pre-loading, uses more bandwidth'; + + @override + String get precachePagesHelp => + 'Controls how many upcoming pages are pre-loaded in the background while reading. Higher values make swiping feel more seamless but use more bandwidth and memory.'; + + @override + String get advancedTitle => 'Advanced'; + + @override + String get advancedDeveloperMode => 'Developer mode'; + + @override + String get advancedDeveloperModeSubtitle => 'Enable debugging tools'; + + @override + String get advancedTests => 'Tests'; + + @override + String get advancedTestsSubtitle => 'Preview UI components'; + + @override + String get advancedDebuggingInfo => 'Debugging info'; + + @override + String get advancedVersion => 'Version'; + + @override + String get advancedBuildNumber => 'Build number'; + + @override + String get advancedPackageName => 'Package name'; + + @override + String get advancedDatabaseVersion => 'Database version'; + + @override + String get advancedPlatform => 'Platform'; + + @override + String get advancedCopyToClipboard => 'Copy to clipboard'; + + @override + String get advancedCopyToClipboardSubtitle => + 'Copy all debug info for bug reports'; + + @override + String get advancedDebugInfoCopied => 'Debug info copied to clipboard'; + + @override + String get advancedFailedToLoadInfo => 'Failed to load info'; + + @override + String get aboutTitle => 'About'; + + @override + String get aboutVersion => 'Version'; + + @override + String get aboutVersionUnknown => 'unknown'; + + @override + String get aboutSourceCode => 'Source code'; + + @override + String get aboutReportIssue => 'Report an issue'; + + @override + String get aboutReportIssueSubtitle => 'Open an issue on the repository'; + + @override + String get devTestsTitle => 'Dev Tests'; + + @override + String get devTestsSnackBars => 'Snack Bars'; + + @override + String get devTestsSuccess => 'Success'; + + @override + String get devTestsError => 'Error'; + + @override + String get devTestsWarning => 'Warning'; + + @override + String get devTestsInfo => 'Info'; + + @override + String get devTestsSuccessMessage => 'This is a success message'; + + @override + String get devTestsErrorMessage => 'This is an error message'; + + @override + String get devTestsWarningMessage => 'This is a warning message'; + + @override + String get devTestsInfoMessage => 'This is an info message'; + + @override + String get serverListTitle => 'My Servers'; + + @override + String get serverListEmpty => 'No Servers Yet'; + + @override + String get serverListEmptySubtitle => + 'Add your first OPDS server to start browsing and reading'; + + @override + String get serverListOffline => + 'You are offline. Connect to the internet to browse servers.'; + + @override + String serverListErrorLoading(String error) { + return 'Error loading servers: $error'; + } + + @override + String get serverListRetry => 'Retry'; + + @override + String get serverListAddServer => 'Add Server'; + + @override + String get serverDeleteTitle => 'Delete Server'; + + @override + String serverDeleteConfirmation(String name) { + return 'Are you sure you want to delete \"$name\"?'; + } + + @override + String serverDeleted(String name) { + return '$name deleted'; + } + + @override + String get addServerTitle => 'Add Server'; + + @override + String get addServerNameLabel => 'Server Name'; + + @override + String get addServerNameHint => 'My Library'; + + @override + String get addServerUrlLabel => 'Server URL'; + + @override + String get addServerUrlHint => 'https://example.com/opds'; + + @override + String get addServerRequiresAuth => 'Requires Authentication'; + + @override + String get addServerRequiresAuthSubtitle => 'Enable if server requires login'; + + @override + String get addServerUsername => 'Username'; + + @override + String get addServerPassword => 'Password'; + + @override + String get addServerValidationNameRequired => 'Please enter a server name'; + + @override + String get addServerValidationUrlRequired => 'Please enter a server URL'; + + @override + String get addServerValidationUrlInvalid => 'Please enter a valid URL'; + + @override + String get addServerValidationUrlScheme => + 'URL must start with http:// or https://'; + + @override + String get addServerValidationUsernameRequired => 'Please enter a username'; + + @override + String get addServerValidationPasswordRequired => 'Please enter a password'; + + @override + String get addServerHelp => + 'Enter the root URL of your OPDS server. Credentials will be securely stored on your device.'; + + @override + String get addServerButton => 'Add Server'; + + @override + String get addServerDuplicate => 'A server with this URL already exists'; + + @override + String addServerSuccess(String name) { + return '$name added successfully'; + } + + @override + String addServerError(String error) { + return 'Error adding server: $error'; + } + + @override + String get editServerTitle => 'Edit Server'; + + @override + String get editServerNotFound => 'Server not found'; + + @override + String get editServerInfo => 'Server Information'; + + @override + String get editServerAdded => 'Added'; + + @override + String get editServerLastSynced => 'Last Synced'; + + @override + String get editServerSaveButton => 'Save Changes'; + + @override + String editServerSuccess(String name) { + return '$name updated successfully'; + } + + @override + String editServerError(String error) { + return 'Error updating server: $error'; + } + + @override + String editServerErrorLoading(String error) { + return 'Error loading server: $error'; + } + + @override + String get readerError => 'Error'; + + @override + String get readerNoStreamLink => + 'This publication cannot be read (no stream link)'; + + @override + String get readerServerNotFound => 'Server not found'; + + @override + String readerErrorLoadingServer(String error) { + return 'Error loading server: $error'; + } + + @override + String readerReadingDirectionWithMode(String mode) { + return 'Reading direction ($mode)'; + } + + @override + String readerPageOf(int current, int total) { + return 'Page $current of $total'; + } + + @override + String readerPagesOf(int start, int end, int total) { + return 'Pages $start-$end of $total'; + } + + @override + String get epubPreparing => 'Preparing...'; + + @override + String get epubLoadingFromCache => 'Loading from cache...'; + + @override + String epubDownloading(int percent) { + return 'Downloading... $percent%'; + } + + @override + String get epubProcessing => 'Processing EPUB...'; + + @override + String get epubOpeningReader => 'Opening reader...'; + + @override + String epubErrorLoading(String error) { + return 'Error loading EPUB: $error'; + } + + @override + String get epubGoBack => 'Go Back'; + + @override + String get epubSearch => 'Search'; + + @override + String get epubSearchHint => 'Enter search term...'; + + @override + String get epubNoResults => 'No results found'; + + @override + String epubSearchResults(int count, String query) { + return '$count results for \"$query\"'; + } + + @override + String epubSearchFailed(String error) { + return 'Search failed: $error'; + } + + @override + String get epubChapters => 'Chapters'; + + @override + String get epubBack => 'Back'; + + @override + String get libraryTitle => 'Library'; + + @override + String get libraryRecentlyRead => 'Recently Read'; + + @override + String get libraryNoServers => 'No Servers Configured'; + + @override + String get libraryNoServersSubtitle => + 'Add an OPDS server to start reading books'; + + @override + String get libraryAddServer => 'Add Server'; + + @override + String get libraryNoHistory => 'No Reading History'; + + @override + String get libraryNoHistorySubtitle => 'Start reading a book to see it here'; + + @override + String get libraryBrowseServers => 'Browse Servers'; + + @override + String get libraryErrorLoading => 'Error Loading Library'; + + @override + String get browserLoading => 'Loading...'; + + @override + String get browserLibrary => 'Library'; + + @override + String get browserNoContent => 'No Content Available'; + + @override + String get browserLibraryEmpty => 'This library appears to be empty'; + + @override + String get browserCollectionEmpty => 'This collection appears to be empty'; + + @override + String get browserFailedToLoadLibrary => 'Failed to Load Library'; + + @override + String get browserFailedToLoadFeed => 'Failed to Load Feed'; + + @override + String get browserRetry => 'Retry'; + + @override + String browserFailedToLoadMore(String error) { + return 'Failed to load more: $error'; + } + + @override + String get browserOfflineEpubNotCached => + 'This EPUB is not cached. You may not be able to read it offline.'; + + @override + String get browserOfflinePagesWarning => + 'You are offline. Pages may not load properly.'; + + @override + String publicationPages(int count) { + return '$count pages'; + } + + @override + String get publicationReadingProgress => 'Reading Progress'; + + @override + String publicationPageOfTotal(int current, int total) { + return 'Page $current of $total'; + } + + @override + String publicationLastRead(String time) { + return 'Last read: $time'; + } + + @override + String get publicationCannotRead => + 'This publication cannot be read in the app'; + + @override + String publicationContinueFromPage(int page) { + return 'Continue reading from page $page'; + } + + @override + String get publicationStartReading => 'Start Reading'; + + @override + String get publicationContinueReading => 'Continue Reading'; + + @override + String get publicationReadAgain => 'Read Again'; + + @override + String get publicationStartFromBeginning => 'Start from Beginning'; + + @override + String get publicationCollection => 'Collection'; + + @override + String get recentlyReadPublicationNotFound => 'Publication not found'; + + @override + String get recentlyReadErrorLoading => 'Error loading publication'; + + @override + String get recentlyReadViewRawData => 'View raw data'; + + @override + String get recentlyReadRemove => 'Remove from Recently Read'; + + @override + String get recentlyReadRemoveTitle => 'Remove from Recently Read'; + + @override + String recentlyReadRemoveConfirmation(String title) { + return 'Remove \"$title\" from your reading history?\n\nThis will not delete the book from your library.'; + } + + @override + String get recentlyReadRemoved => 'Removed from Recently Read'; + + @override + String recentlyReadRemoveFailed(String error) { + return 'Failed to remove: $error'; + } + + @override + String get recentlyReadRawData => 'Raw Data'; + + @override + String get recentlyReadOfflineEpubNotCached => + 'This EPUB is not cached. Connect to internet to download.'; + + @override + String get recentlyReadOfflinePagesWarning => + 'You are offline. Pages may not load.'; + + @override + String get recentlyReadUnsupportedFormat => 'Unsupported format'; + + @override + String get serverCardEdit => 'Edit'; + + @override + String get serverCardDelete => 'Delete'; + + @override + String get serverCardAuthenticated => 'Authenticated'; + + @override + String serverCardLastSynced(String time) { + return 'Last synced: $time'; + } + + @override + String get offlineSemanticLabel => 'Offline'; + + @override + String get nextInSeriesNext => 'Next'; + + @override + String get nextInSeriesFinished => 'Finished'; + + @override + String get nextInSeriesBackToDetails => 'Back to details'; + + @override + String get nextInSeriesBackToLibrary => 'Back to library'; + + @override + String routeNotFound(String uri) { + return 'Page not found: $uri'; + } + + @override + String get commonCancel => 'Cancel'; + + @override + String get commonDelete => 'Delete'; + + @override + String get commonRemove => 'Remove'; + + @override + String get timeJustNow => 'just now'; + + @override + String timeMinutesAgo(int minutes) { + return '${minutes}m ago'; + } + + @override + String timeHoursAgo(int hours) { + return '${hours}h ago'; + } + + @override + String timeDaysAgo(int days) { + return '${days}d ago'; + } + + @override + String timeWeeksAgo(int weeks) { + return '${weeks}w ago'; + } + + @override + String timeMonthsAgo(int months) { + return '${months}mo ago'; + } + + @override + String timeYearsAgo(int years) { + return '${years}y ago'; + } + + @override + String get timeToday => 'Today'; + + @override + String get timeYesterday => 'Yesterday'; + + @override + String timeMinAgo(int minutes) { + return '$minutes min ago'; + } + + @override + String timeHoursAgoLong(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'hours', + one: 'hour', + ); + return '$count $_temp0 ago'; + } + + @override + String timeDaysAgoLong(int days) { + return '$days days ago'; + } +} diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart new file mode 100644 index 0000000..57cd1a6 --- /dev/null +++ b/lib/l10n/app_localizations_es.dart @@ -0,0 +1,794 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Spanish Castilian (`es`). +class AppLocalizationsEs extends AppLocalizations { + AppLocalizationsEs([String locale = 'es']) : super(locale); + + @override + String get appName => 'Worldhopper'; + + @override + String get navLibrary => 'Biblioteca'; + + @override + String get navServers => 'Servidores'; + + @override + String get navSettings => 'Ajustes'; + + @override + String get settingsTitle => 'Ajustes'; + + @override + String get settingsAppearance => 'Apariencia'; + + @override + String get settingsAppearanceSubtitle => 'Opciones de visualización'; + + @override + String get settingsLanguage => 'Idioma'; + + @override + String get settingsLanguageSubtitle => 'Idioma de la aplicación'; + + @override + String get settingsReaders => 'Lectores'; + + @override + String get settingsReadersSubtitle => 'Opciones de lectura'; + + @override + String get settingsAdvanced => 'Avanzado'; + + @override + String get settingsAdvancedSubtitle => 'Desarrollo y depuración'; + + @override + String get settingsAbout => 'Acerca de'; + + @override + String get settingsAboutSubtitle => 'Worldhopper'; + + @override + String get appearanceTitle => 'Apariencia'; + + @override + String get appearanceTheme => 'Tema'; + + @override + String get themeLight => 'Claro'; + + @override + String get themeDark => 'Oscuro'; + + @override + String get themeSystem => 'Sistema'; + + @override + String get themeSystemSubtitle => 'Seguir el tema del dispositivo'; + + @override + String get languageTitle => 'Idioma'; + + @override + String get languageSystem => 'Predeterminado del sistema'; + + @override + String get languageSystemSubtitle => 'Usar el idioma del dispositivo'; + + @override + String get readersTitle => 'Lectores'; + + @override + String get readersImageReaderSection => 'Lector de imágenes'; + + @override + String get readersReadingDirection => 'Dirección de lectura'; + + @override + String get readersFilterQuality => 'Calidad de filtro'; + + @override + String get readersPrecachePages => 'Precarga de páginas'; + + @override + String get readersTwoPageSpread => 'Doble página'; + + @override + String get readersTwoPageSpreadSubtitle => + 'Mostrar dos páginas lado a lado en pantallas anchas'; + + @override + String get readersFirstPageIsCover => 'La primera página es portada'; + + @override + String get readersFirstPageIsCoverSubtitle => + 'Mostrar la primera página sola en doble página'; + + @override + String get readingDirectionTitle => 'Dirección de lectura'; + + @override + String get readingModeLtr => 'Izquierda a derecha'; + + @override + String get readingModeLtrDescription => + 'Orden de lectura occidental estándar'; + + @override + String get readingModeRtl => 'Derecha a izquierda'; + + @override + String get readingModeRtlDescription => 'Orden de lectura estilo manga'; + + @override + String get readingModeVertical => 'Desplazamiento vertical'; + + @override + String get readingModeVerticalDescription => + 'Desplazamiento continuo estilo webtoon'; + + @override + String get readingDirectionHelp => + 'Controla la dirección de lectura del lector de imágenes. Izquierda a derecha es el estándar para cómics occidentales, derecha a izquierda para manga, y desplazamiento vertical para webtoons.'; + + @override + String get readingModeShortLtr => 'IaD'; + + @override + String get readingModeShortRtl => 'DaI'; + + @override + String get readingModeShortVertical => 'Vertical'; + + @override + String get filterQualityTitle => 'Calidad de filtro'; + + @override + String get filterQualityNone => 'Ninguna'; + + @override + String get filterQualityNoneDescription => 'Más rápido, puede verse pixelado'; + + @override + String get filterQualityLow => 'Baja'; + + @override + String get filterQualityLowDescription => 'Interpolación bilineal'; + + @override + String get filterQualityMedium => 'Media'; + + @override + String get filterQualityMediumDescription => + 'Bilineal con mipmaps, buen equilibrio'; + + @override + String get filterQualityHigh => 'Alta'; + + @override + String get filterQualityHighDescription => + 'Interpolación bicúbica, mejor calidad'; + + @override + String get filterQualityHelp => + 'Controla cómo se escalan las imágenes cuando una página se reduce para ajustarse a la pantalla. Mayor calidad hace que el texto y los detalles finos sean más nítidos, pero usa más recursos de GPU. Se recomienda Media para la mayoría de dispositivos.'; + + @override + String get precachePagesTitle => 'Precarga de páginas'; + + @override + String get precachePagesOff => 'Desactivada'; + + @override + String get precachePagesOffDescription => + 'Las páginas se cargan bajo demanda'; + + @override + String get precachePages1 => '1 página'; + + @override + String get precachePages1Description => 'Precarga mínima'; + + @override + String get precachePages2 => '2 páginas'; + + @override + String get precachePages2Description => 'Precarga ligera'; + + @override + String get precachePages3 => '3 páginas'; + + @override + String get precachePages3Description => 'Equilibrada, recomendada'; + + @override + String get precachePages4 => '4 páginas'; + + @override + String get precachePages4Description => 'Precarga agresiva'; + + @override + String get precachePages5 => '5 páginas'; + + @override + String get precachePages5Description => + 'Precarga máxima, usa más ancho de banda'; + + @override + String get precachePagesHelp => + 'Controla cuántas páginas siguientes se precargan en segundo plano mientras lees. Valores más altos hacen que pasar página sea más fluido, pero usan más ancho de banda y memoria.'; + + @override + String get advancedTitle => 'Avanzado'; + + @override + String get advancedDeveloperMode => 'Modo desarrollador'; + + @override + String get advancedDeveloperModeSubtitle => + 'Activar herramientas de depuración'; + + @override + String get advancedTests => 'Pruebas'; + + @override + String get advancedTestsSubtitle => 'Previsualizar componentes de interfaz'; + + @override + String get advancedDebuggingInfo => 'Información de depuración'; + + @override + String get advancedVersion => 'Versión'; + + @override + String get advancedBuildNumber => 'Número de compilación'; + + @override + String get advancedPackageName => 'Nombre del paquete'; + + @override + String get advancedDatabaseVersion => 'Versión de la base de datos'; + + @override + String get advancedPlatform => 'Plataforma'; + + @override + String get advancedCopyToClipboard => 'Copiar al portapapeles'; + + @override + String get advancedCopyToClipboardSubtitle => + 'Copiar toda la información de depuración para informes de errores'; + + @override + String get advancedDebugInfoCopied => + 'Información de depuración copiada al portapapeles'; + + @override + String get advancedFailedToLoadInfo => 'Error al cargar la información'; + + @override + String get aboutTitle => 'Acerca de'; + + @override + String get aboutVersion => 'Versión'; + + @override + String get aboutVersionUnknown => 'desconocida'; + + @override + String get aboutSourceCode => 'Código fuente'; + + @override + String get aboutReportIssue => 'Reportar un problema'; + + @override + String get aboutReportIssueSubtitle => 'Abrir un informe en el repositorio'; + + @override + String get devTestsTitle => 'Pruebas de desarrollo'; + + @override + String get devTestsSnackBars => 'Barras de notificación'; + + @override + String get devTestsSuccess => 'Éxito'; + + @override + String get devTestsError => 'Error'; + + @override + String get devTestsWarning => 'Advertencia'; + + @override + String get devTestsInfo => 'Información'; + + @override + String get devTestsSuccessMessage => 'Este es un mensaje de éxito'; + + @override + String get devTestsErrorMessage => 'Este es un mensaje de error'; + + @override + String get devTestsWarningMessage => 'Este es un mensaje de advertencia'; + + @override + String get devTestsInfoMessage => 'Este es un mensaje informativo'; + + @override + String get serverListTitle => 'Mis servidores'; + + @override + String get serverListEmpty => 'Sin servidores'; + + @override + String get serverListEmptySubtitle => + 'Añade tu primer servidor OPDS para empezar a explorar y leer'; + + @override + String get serverListOffline => + 'No hay conexión. Conéctate a internet para explorar servidores.'; + + @override + String serverListErrorLoading(String error) { + return 'Error al cargar servidores: $error'; + } + + @override + String get serverListRetry => 'Reintentar'; + + @override + String get serverListAddServer => 'Añadir servidor'; + + @override + String get serverDeleteTitle => 'Eliminar servidor'; + + @override + String serverDeleteConfirmation(String name) { + return '¿Seguro que quieres eliminar \"$name\"?'; + } + + @override + String serverDeleted(String name) { + return '$name eliminado'; + } + + @override + String get addServerTitle => 'Añadir servidor'; + + @override + String get addServerNameLabel => 'Nombre del servidor'; + + @override + String get addServerNameHint => 'Mi biblioteca'; + + @override + String get addServerUrlLabel => 'URL del servidor'; + + @override + String get addServerUrlHint => 'https://ejemplo.com/opds'; + + @override + String get addServerRequiresAuth => 'Requiere autenticación'; + + @override + String get addServerRequiresAuthSubtitle => + 'Activar si el servidor requiere inicio de sesión'; + + @override + String get addServerUsername => 'Usuario'; + + @override + String get addServerPassword => 'Contraseña'; + + @override + String get addServerValidationNameRequired => + 'Introduce un nombre de servidor'; + + @override + String get addServerValidationUrlRequired => 'Introduce una URL de servidor'; + + @override + String get addServerValidationUrlInvalid => 'Introduce una URL válida'; + + @override + String get addServerValidationUrlScheme => + 'La URL debe empezar con http:// o https://'; + + @override + String get addServerValidationUsernameRequired => + 'Introduce un nombre de usuario'; + + @override + String get addServerValidationPasswordRequired => 'Introduce una contraseña'; + + @override + String get addServerHelp => + 'Introduce la URL raíz de tu servidor OPDS. Las credenciales se almacenarán de forma segura en tu dispositivo.'; + + @override + String get addServerButton => 'Añadir servidor'; + + @override + String get addServerDuplicate => 'Ya existe un servidor con esta URL'; + + @override + String addServerSuccess(String name) { + return '$name añadido correctamente'; + } + + @override + String addServerError(String error) { + return 'Error al añadir servidor: $error'; + } + + @override + String get editServerTitle => 'Editar servidor'; + + @override + String get editServerNotFound => 'Servidor no encontrado'; + + @override + String get editServerInfo => 'Información del servidor'; + + @override + String get editServerAdded => 'Añadido'; + + @override + String get editServerLastSynced => 'Última sincronización'; + + @override + String get editServerSaveButton => 'Guardar cambios'; + + @override + String editServerSuccess(String name) { + return '$name actualizado correctamente'; + } + + @override + String editServerError(String error) { + return 'Error al actualizar servidor: $error'; + } + + @override + String editServerErrorLoading(String error) { + return 'Error al cargar servidor: $error'; + } + + @override + String get readerError => 'Error'; + + @override + String get readerNoStreamLink => + 'Esta publicación no se puede leer (sin enlace de transmisión)'; + + @override + String get readerServerNotFound => 'Servidor no encontrado'; + + @override + String readerErrorLoadingServer(String error) { + return 'Error al cargar servidor: $error'; + } + + @override + String readerReadingDirectionWithMode(String mode) { + return 'Dirección de lectura ($mode)'; + } + + @override + String readerPageOf(int current, int total) { + return 'Página $current de $total'; + } + + @override + String readerPagesOf(int start, int end, int total) { + return 'Páginas $start-$end de $total'; + } + + @override + String get epubPreparing => 'Preparando...'; + + @override + String get epubLoadingFromCache => 'Cargando desde caché...'; + + @override + String epubDownloading(int percent) { + return 'Descargando... $percent%'; + } + + @override + String get epubProcessing => 'Procesando EPUB...'; + + @override + String get epubOpeningReader => 'Abriendo lector...'; + + @override + String epubErrorLoading(String error) { + return 'Error al cargar EPUB: $error'; + } + + @override + String get epubGoBack => 'Volver'; + + @override + String get epubSearch => 'Buscar'; + + @override + String get epubSearchHint => 'Introduce un término de búsqueda...'; + + @override + String get epubNoResults => 'No se encontraron resultados'; + + @override + String epubSearchResults(int count, String query) { + return '$count resultados para \"$query\"'; + } + + @override + String epubSearchFailed(String error) { + return 'Error en la búsqueda: $error'; + } + + @override + String get epubChapters => 'Capítulos'; + + @override + String get epubBack => 'Atrás'; + + @override + String get libraryTitle => 'Biblioteca'; + + @override + String get libraryRecentlyRead => 'Leídos recientemente'; + + @override + String get libraryNoServers => 'Sin servidores configurados'; + + @override + String get libraryNoServersSubtitle => + 'Añade un servidor OPDS para empezar a leer libros'; + + @override + String get libraryAddServer => 'Añadir servidor'; + + @override + String get libraryNoHistory => 'Sin historial de lectura'; + + @override + String get libraryNoHistorySubtitle => + 'Empieza a leer un libro para verlo aquí'; + + @override + String get libraryBrowseServers => 'Explorar servidores'; + + @override + String get libraryErrorLoading => 'Error al cargar la biblioteca'; + + @override + String get browserLoading => 'Cargando...'; + + @override + String get browserLibrary => 'Biblioteca'; + + @override + String get browserNoContent => 'Sin contenido disponible'; + + @override + String get browserLibraryEmpty => 'Esta biblioteca parece estar vacía'; + + @override + String get browserCollectionEmpty => 'Esta colección parece estar vacía'; + + @override + String get browserFailedToLoadLibrary => 'Error al cargar la biblioteca'; + + @override + String get browserFailedToLoadFeed => 'Error al cargar el feed'; + + @override + String get browserRetry => 'Reintentar'; + + @override + String browserFailedToLoadMore(String error) { + return 'Error al cargar más: $error'; + } + + @override + String get browserOfflineEpubNotCached => + 'Este EPUB no está en caché. Es posible que no puedas leerlo sin conexión.'; + + @override + String get browserOfflinePagesWarning => + 'No hay conexión. Las páginas podrían no cargarse correctamente.'; + + @override + String publicationPages(int count) { + return '$count páginas'; + } + + @override + String get publicationReadingProgress => 'Progreso de lectura'; + + @override + String publicationPageOfTotal(int current, int total) { + return 'Página $current de $total'; + } + + @override + String publicationLastRead(String time) { + return 'Última lectura: $time'; + } + + @override + String get publicationCannotRead => + 'Esta publicación no se puede leer en la aplicación'; + + @override + String publicationContinueFromPage(int page) { + return 'Continuar leyendo desde la página $page'; + } + + @override + String get publicationStartReading => 'Empezar a leer'; + + @override + String get publicationContinueReading => 'Continuar leyendo'; + + @override + String get publicationReadAgain => 'Leer de nuevo'; + + @override + String get publicationStartFromBeginning => 'Empezar desde el principio'; + + @override + String get publicationCollection => 'Colección'; + + @override + String get recentlyReadPublicationNotFound => 'Publicación no encontrada'; + + @override + String get recentlyReadErrorLoading => 'Error al cargar la publicación'; + + @override + String get recentlyReadViewRawData => 'Ver datos sin procesar'; + + @override + String get recentlyReadRemove => 'Eliminar de leídos recientes'; + + @override + String get recentlyReadRemoveTitle => 'Eliminar de leídos recientes'; + + @override + String recentlyReadRemoveConfirmation(String title) { + return '¿Eliminar \"$title\" de tu historial de lectura?\n\nEsto no eliminará el libro de tu biblioteca.'; + } + + @override + String get recentlyReadRemoved => 'Eliminado de leídos recientes'; + + @override + String recentlyReadRemoveFailed(String error) { + return 'Error al eliminar: $error'; + } + + @override + String get recentlyReadRawData => 'Datos sin procesar'; + + @override + String get recentlyReadOfflineEpubNotCached => + 'Este EPUB no está en caché. Conéctate a internet para descargarlo.'; + + @override + String get recentlyReadOfflinePagesWarning => + 'No hay conexión. Las páginas podrían no cargarse.'; + + @override + String get recentlyReadUnsupportedFormat => 'Formato no compatible'; + + @override + String get serverCardEdit => 'Editar'; + + @override + String get serverCardDelete => 'Eliminar'; + + @override + String get serverCardAuthenticated => 'Autenticado'; + + @override + String serverCardLastSynced(String time) { + return 'Última sincronización: $time'; + } + + @override + String get offlineSemanticLabel => 'Sin conexión'; + + @override + String get nextInSeriesNext => 'Siguiente'; + + @override + String get nextInSeriesFinished => 'Terminado'; + + @override + String get nextInSeriesBackToDetails => 'Volver a detalles'; + + @override + String get nextInSeriesBackToLibrary => 'Volver a la biblioteca'; + + @override + String routeNotFound(String uri) { + return 'Página no encontrada: $uri'; + } + + @override + String get commonCancel => 'Cancelar'; + + @override + String get commonDelete => 'Eliminar'; + + @override + String get commonRemove => 'Eliminar'; + + @override + String get timeJustNow => 'ahora mismo'; + + @override + String timeMinutesAgo(int minutes) { + return 'hace $minutes min'; + } + + @override + String timeHoursAgo(int hours) { + return 'hace $hours h'; + } + + @override + String timeDaysAgo(int days) { + return 'hace $days d'; + } + + @override + String timeWeeksAgo(int weeks) { + return 'hace $weeks sem'; + } + + @override + String timeMonthsAgo(int months) { + return 'hace $months mes(es)'; + } + + @override + String timeYearsAgo(int years) { + return 'hace $years año(s)'; + } + + @override + String get timeToday => 'Hoy'; + + @override + String get timeYesterday => 'Ayer'; + + @override + String timeMinAgo(int minutes) { + return 'hace $minutes min'; + } + + @override + String timeHoursAgoLong(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'horas', + one: 'hora', + ); + return 'hace $count $_temp0'; + } + + @override + String timeDaysAgoLong(int days) { + return 'hace $days días'; + } +} diff --git a/lib/providers/locale_provider.dart b/lib/providers/locale_provider.dart new file mode 100644 index 0000000..e1c433b --- /dev/null +++ b/lib/providers/locale_provider.dart @@ -0,0 +1,49 @@ +import 'dart:ui'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'locale_provider.g.dart'; + +/// Key for storing locale preference in SharedPreferences +const String _localeKey = 'app_locale'; + +/// Provider for locale state management +/// State is Locale? where null means "use system default" +@riverpod +class LocaleNotifier extends _$LocaleNotifier { + @override + Locale? build() { + _loadLocale(); + return null; // Default: system locale + } + + /// Load locale from SharedPreferences + Future _loadLocale() async { + final prefs = await SharedPreferences.getInstance(); + final localeString = prefs.getString(_localeKey); + + if (localeString != null) { + final parts = localeString.split('_'); + final locale = parts.length > 1 + ? Locale(parts[0], parts[1]) + : Locale(parts[0]); + state = locale; + } + } + + /// Set locale and persist to SharedPreferences + /// Pass null to use system default + Future setLocale(Locale? locale) async { + state = locale; + final prefs = await SharedPreferences.getInstance(); + if (locale == null) { + await prefs.remove(_localeKey); + } else { + final localeString = locale.countryCode != null + ? '${locale.languageCode}_${locale.countryCode}' + : locale.languageCode; + await prefs.setString(_localeKey, localeString); + } + } +} diff --git a/lib/providers/locale_provider.g.dart b/lib/providers/locale_provider.g.dart new file mode 100644 index 0000000..f16f8f0 --- /dev/null +++ b/lib/providers/locale_provider.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'locale_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$localeNotifierHash() => r'fcc5a3f01da5605ee004d63f70fbb733bd2723de'; + +/// Provider for locale state management +/// State is Locale? where null means "use system default" +/// +/// Copied from [LocaleNotifier]. +@ProviderFor(LocaleNotifier) +final localeNotifierProvider = + AutoDisposeNotifierProvider.internal( + LocaleNotifier.new, + name: r'localeNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$localeNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$LocaleNotifier = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/screens/browse/feed_screen.dart b/lib/screens/browse/feed_screen.dart index 0642eca..f278e3d 100644 --- a/lib/screens/browse/feed_screen.dart +++ b/lib/screens/browse/feed_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/helpers/snackbar_helper.dart'; import 'package:worldhopper/models/opds_feed.dart'; @@ -83,6 +84,8 @@ class _FeedScreenState extends ConsumerState { } Widget _buildFeedContent(BuildContext context, WidgetRef ref, OPDSFeed feed) { + final l10n = AppLocalizations.of(context); + // Initialize entries only once when first loaded if (!_initialized) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -122,7 +125,7 @@ class _FeedScreenState extends ConsumerState { await ref.read(opdsFeedProvider(refreshRequest).future); }, child: _allEntries.isEmpty - ? _buildEmptyState(context) + ? _buildEmptyState(context, l10n) : LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( @@ -196,14 +199,14 @@ class _FeedScreenState extends ConsumerState { // Warn for uncached EPUBs if (!context.mounted) return; context.showInfoSnackBar( - 'This EPUB is not cached. You may not be able to read it offline.', + l10n.browserOfflineEpubNotCached, duration: const Duration(seconds: 3), ); } else if (entry.hasStreamLink) { // Warn for OPDS-PS streams if (!context.mounted) return; context.showInfoSnackBar( - 'You are offline. Pages may not load properly.', + l10n.browserOfflinePagesWarning, duration: const Duration(seconds: 3), ); } @@ -257,12 +260,13 @@ class _FeedScreenState extends ConsumerState { }); if (mounted) { - context.showErrorSnackBar('Failed to load more: $e'); + final l10n = AppLocalizations.of(context); + context.showErrorSnackBar(l10n.browserFailedToLoadMore(e.toString())); } } } - Widget _buildEmptyState(BuildContext context) { + Widget _buildEmptyState(BuildContext context, AppLocalizations l10n) { return LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( @@ -282,12 +286,12 @@ class _FeedScreenState extends ConsumerState { ), const SizedBox(height: 24), Text( - 'No Content Available', + l10n.browserNoContent, style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 8), Text( - 'This collection appears to be empty', + l10n.browserCollectionEmpty, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), @@ -303,6 +307,8 @@ class _FeedScreenState extends ConsumerState { } Widget _buildErrorState(BuildContext context, WidgetRef ref, Object error) { + final l10n = AppLocalizations.of(context); + return Center( child: Padding( padding: const EdgeInsets.all(32), @@ -312,7 +318,7 @@ class _FeedScreenState extends ConsumerState { const Icon(Icons.error_outline, size: 64, color: Colors.red), const SizedBox(height: 24), Text( - 'Failed to Load Feed', + l10n.browserFailedToLoadFeed, style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.center, ), @@ -333,7 +339,7 @@ class _FeedScreenState extends ConsumerState { ref.invalidate(opdsFeedProvider(refreshRequest)); }, icon: const Icon(Icons.refresh), - label: const Text('Retry'), + label: Text(l10n.browserRetry), ), ], ), diff --git a/lib/screens/browse/library_browser_screen.dart b/lib/screens/browse/library_browser_screen.dart index b1edcdd..1e63e8f 100644 --- a/lib/screens/browse/library_browser_screen.dart +++ b/lib/screens/browse/library_browser_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/helpers/snackbar_helper.dart'; import 'package:worldhopper/models/opds_feed.dart'; @@ -59,6 +60,7 @@ class _LibraryBrowserScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); final serverAsync = ref.watch(serverProvider(widget.serverId)); final feedRequest = RootFeedRequest(serverId: widget.serverId); final feedAsync = ref.watch(opdsRootFeedProvider(feedRequest)); @@ -66,9 +68,9 @@ class _LibraryBrowserScreenState extends ConsumerState { return Scaffold( appBar: WorldhopperAppBar( title: serverAsync.when( - data: (server) => Text(server?.name ?? 'Library'), - loading: () => const Text('Loading...'), - error: (_, __) => const Text('Library'), + data: (server) => Text(server?.name ?? l10n.browserLibrary), + loading: () => Text(l10n.browserLoading), + error: (_, __) => Text(l10n.browserLibrary), ), ), body: feedAsync.when( @@ -80,6 +82,8 @@ class _LibraryBrowserScreenState extends ConsumerState { } Widget _buildFeedContent(BuildContext context, WidgetRef ref, OPDSFeed feed) { + final l10n = AppLocalizations.of(context); + // Initialize entries only once when first loaded if (!_initialized) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -115,7 +119,7 @@ class _LibraryBrowserScreenState extends ConsumerState { await ref.read(opdsRootFeedProvider(refreshRequest).future); }, child: _allEntries.isEmpty - ? _buildEmptyState(context) + ? _buildEmptyState(context, l10n) : LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( @@ -189,14 +193,14 @@ class _LibraryBrowserScreenState extends ConsumerState { // Warn for uncached EPUBs if (!context.mounted) return; context.showInfoSnackBar( - 'This EPUB is not cached. You may not be able to read it offline.', + l10n.browserOfflineEpubNotCached, duration: const Duration(seconds: 3), ); } else if (entry.hasStreamLink) { // Warn for OPDS-PS streams if (!context.mounted) return; context.showInfoSnackBar( - 'You are offline. Pages may not load properly.', + l10n.browserOfflinePagesWarning, duration: const Duration(seconds: 3), ); } @@ -249,12 +253,13 @@ class _LibraryBrowserScreenState extends ConsumerState { }); if (mounted) { - context.showErrorSnackBar('Failed to load more: $e'); + final l10n = AppLocalizations.of(context); + context.showErrorSnackBar(l10n.browserFailedToLoadMore(e.toString())); } } } - Widget _buildEmptyState(BuildContext context) { + Widget _buildEmptyState(BuildContext context, AppLocalizations l10n) { return LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( @@ -274,12 +279,12 @@ class _LibraryBrowserScreenState extends ConsumerState { ), const SizedBox(height: 24), Text( - 'No Content Available', + l10n.browserNoContent, style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 8), Text( - 'This library appears to be empty', + l10n.browserLibraryEmpty, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), @@ -295,6 +300,8 @@ class _LibraryBrowserScreenState extends ConsumerState { } Widget _buildErrorState(BuildContext context, WidgetRef ref, Object error) { + final l10n = AppLocalizations.of(context); + return Center( child: Padding( padding: const EdgeInsets.all(32), @@ -304,7 +311,7 @@ class _LibraryBrowserScreenState extends ConsumerState { const Icon(Icons.error_outline, size: 64, color: Colors.red), const SizedBox(height: 24), Text( - 'Failed to Load Library', + l10n.browserFailedToLoadLibrary, style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.center, ), @@ -321,7 +328,7 @@ class _LibraryBrowserScreenState extends ConsumerState { ref.invalidate(opdsRootFeedProvider(refreshRequest)); }, icon: const Icon(Icons.refresh), - label: const Text('Retry'), + label: Text(l10n.browserRetry), ), ], ), diff --git a/lib/screens/library/library_screen.dart b/lib/screens/library/library_screen.dart index d343b03..7b86465 100644 --- a/lib/screens/library/library_screen.dart +++ b/lib/screens/library/library_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/providers/reading_progress_provider.dart'; import 'package:worldhopper/providers/server_provider.dart'; @@ -13,11 +14,12 @@ class LibraryScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); final recentlyReadAsync = ref.watch(recentlyReadProvider); return Scaffold( - appBar: const WorldhopperAppBar( - title: Text('Library'), + appBar: WorldhopperAppBar( + title: Text(l10n.libraryTitle), ), body: RefreshIndicator( onRefresh: () async { @@ -26,18 +28,18 @@ class LibraryScreen extends ConsumerWidget { child: recentlyReadAsync.when( data: (recentlyRead) { if (recentlyRead.isEmpty) { - return _buildEmptyState(context, ref); + return _buildEmptyState(context, ref, l10n); } return CustomScrollView( slivers: [ // Recently read section header - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 8.0), + padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 8.0), child: Text( - 'Recently Read', - style: TextStyle( + l10n.libraryRecentlyRead, + style: const TextStyle( fontSize: 20, fontWeight: FontWeight.bold, ), @@ -72,31 +74,31 @@ class LibraryScreen extends ConsumerWidget { ); }, loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => _buildErrorState(context, error), + error: (error, stack) => _buildErrorState(context, l10n, error), ), ), ); } - Widget _buildEmptyState(BuildContext context, WidgetRef ref) { + Widget _buildEmptyState(BuildContext context, WidgetRef ref, AppLocalizations l10n) { final serversAsync = ref.watch(serverListProvider); return serversAsync.when( data: (servers) { // Case 1: No servers configured if (servers.isEmpty) { - return _buildNoServersState(context); + return _buildNoServersState(context, l10n); } // Case 2: Servers exist but no reading history - return _buildNoHistoryState(context); + return _buildNoHistoryState(context, l10n); }, loading: () => const Center(child: CircularProgressIndicator()), - error: (_, __) => _buildNoHistoryState(context), // Fallback + error: (_, __) => _buildNoHistoryState(context, l10n), // Fallback ); } - Widget _buildNoServersState(BuildContext context) { + Widget _buildNoServersState(BuildContext context, AppLocalizations l10n) { return Center( child: Padding( padding: const EdgeInsets.all(32.0), @@ -110,13 +112,13 @@ class LibraryScreen extends ConsumerWidget { ), const SizedBox(height: 24), Text( - 'No Servers Configured', + l10n.libraryNoServers, style: Theme.of(context).textTheme.headlineSmall, textAlign: TextAlign.center, ), const SizedBox(height: 12), Text( - 'Add an OPDS server to start reading books', + l10n.libraryNoServersSubtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), ), @@ -126,7 +128,7 @@ class LibraryScreen extends ConsumerWidget { FilledButton.icon( onPressed: () => context.go(AppConstants.routeServerList), icon: const Icon(Icons.add), - label: const Text('Add Server'), + label: Text(l10n.libraryAddServer), ), ], ), @@ -134,7 +136,7 @@ class LibraryScreen extends ConsumerWidget { ); } - Widget _buildNoHistoryState(BuildContext context) { + Widget _buildNoHistoryState(BuildContext context, AppLocalizations l10n) { return Center( child: Padding( padding: const EdgeInsets.all(32.0), @@ -148,13 +150,13 @@ class LibraryScreen extends ConsumerWidget { ), const SizedBox(height: 24), Text( - 'No Reading History', + l10n.libraryNoHistory, style: Theme.of(context).textTheme.headlineSmall, textAlign: TextAlign.center, ), const SizedBox(height: 12), Text( - 'Start reading a book to see it here', + l10n.libraryNoHistorySubtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), ), @@ -164,7 +166,7 @@ class LibraryScreen extends ConsumerWidget { FilledButton.icon( onPressed: () => context.go(AppConstants.routeServerList), icon: const Icon(Icons.dns), - label: const Text('Browse Servers'), + label: Text(l10n.libraryBrowseServers), ), ], ), @@ -172,7 +174,7 @@ class LibraryScreen extends ConsumerWidget { ); } - Widget _buildErrorState(BuildContext context, Object error) { + Widget _buildErrorState(BuildContext context, AppLocalizations l10n, Object error) { return Center( child: Padding( padding: const EdgeInsets.all(32.0), @@ -186,7 +188,7 @@ class LibraryScreen extends ConsumerWidget { ), const SizedBox(height: 24), Text( - 'Error Loading Library', + l10n.libraryErrorLoading, style: Theme.of(context).textTheme.headlineSmall, textAlign: TextAlign.center, ), diff --git a/lib/screens/publication/publication_detail_screen.dart b/lib/screens/publication/publication_detail_screen.dart index 8d1d9a8..cd3b1bb 100644 --- a/lib/screens/publication/publication_detail_screen.dart +++ b/lib/screens/publication/publication_detail_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/models/opds_entry.dart'; import 'package:worldhopper/providers/reading_progress_provider.dart'; @@ -35,7 +36,6 @@ class PublicationDetailScreen extends ConsumerWidget { child: SafeArea( bottom: false, child: Scaffold( - // extendBodyBehindAppBar: true, appBar: _buildAppBar(context, ref), body: ListView( padding: EdgeInsets.zero, @@ -58,6 +58,8 @@ class PublicationDetailScreen extends ConsumerWidget { } PreferredSizeWidget _buildAppBar(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + // Watch connectivity state final connectivityState = ref.watch(connectivityStateProvider); @@ -77,7 +79,7 @@ class PublicationDetailScreen extends ConsumerWidget { child: Icon( Icons.cloud_off, color: Theme.of(context).colorScheme.error, - semanticLabel: 'Offline', + semanticLabel: l10n.offlineSemanticLabel, ), ), ] @@ -101,7 +103,6 @@ class PublicationDetailScreen extends ConsumerWidget { return CachedNetworkImage( imageUrl: imageUrl, - // fit: BoxFit.cover, placeholder: (context, url) => Container( color: Theme.of(context).colorScheme.surfaceContainerHighest, child: const Center(child: CircularProgressIndicator()), @@ -118,6 +119,8 @@ class PublicationDetailScreen extends ConsumerWidget { } Widget _buildMetadata(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Wrap( @@ -128,7 +131,7 @@ class PublicationDetailScreen extends ConsumerWidget { if (entry.hasStreamLink) Chip( avatar: const Icon(Icons.auto_stories, size: 18), - label: Text('${entry.streamLink!.pageCount} pages'), + label: Text(l10n.publicationPages(entry.streamLink!.pageCount)), ), if (entry.categories.isNotEmpty) ...entry.categories.map( @@ -154,6 +157,8 @@ class PublicationDetailScreen extends ConsumerWidget { WidgetRef ref, AsyncValue progressAsync, ) { + final l10n = AppLocalizations.of(context); + return progressAsync.when( data: (progress) { if (progress == null) { @@ -166,7 +171,7 @@ class PublicationDetailScreen extends ConsumerWidget { return _buildProgressCardContent( context, percentage: percentage, - label: 'Page ${lastRead + 1} of $pageCount', + label: l10n.publicationPageOfTotal(lastRead + 1, pageCount), ); } return const SizedBox.shrink(); @@ -176,8 +181,8 @@ class PublicationDetailScreen extends ConsumerWidget { context, percentage: progress.progressPercentage, label: entry.isEpub - ? 'Last read: ${_formatDateTime(progress.lastReadAt)}' - : 'Page ${progress.currentPage + 1} of ${progress.totalPages}', + ? l10n.publicationLastRead(_formatDateTime(context, progress.lastReadAt)) + : l10n.publicationPageOfTotal(progress.currentPage + 1, progress.totalPages), ); }, loading: () => const SizedBox.shrink(), @@ -190,6 +195,8 @@ class PublicationDetailScreen extends ConsumerWidget { required double percentage, required String label, }) { + final l10n = AppLocalizations.of(context); + return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Card( @@ -202,7 +209,7 @@ class PublicationDetailScreen extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Reading Progress', + l10n.publicationReadingProgress, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), @@ -239,6 +246,8 @@ class PublicationDetailScreen extends ConsumerWidget { WidgetRef ref, AsyncValue progressAsync, ) { + final l10n = AppLocalizations.of(context); + // Check if entry can be read (either as stream or EPUB) final canRead = entry.hasStreamLink || entry.isEpub; @@ -258,7 +267,7 @@ class PublicationDetailScreen extends ConsumerWidget { const SizedBox(width: 12), Expanded( child: Text( - 'This publication cannot be read in the app', + l10n.publicationCannotRead, style: TextStyle( color: Theme.of(context).colorScheme.onErrorContainer, ), @@ -293,7 +302,7 @@ class PublicationDetailScreen extends ConsumerWidget { ), icon: const Icon(Icons.play_arrow), label: Text( - 'Continue reading from page ${entry.streamLink!.lastRead! + 1}', + l10n.publicationContinueFromPage(entry.streamLink!.lastRead! + 1), ), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), @@ -303,7 +312,7 @@ class PublicationDetailScreen extends ConsumerWidget { OutlinedButton.icon( onPressed: () => _navigateToReader(context, ref, 0), icon: const Icon(Icons.restart_alt), - label: const Text('Start reading'), + label: Text(l10n.publicationStartReading), ), ] else ...[ FilledButton.icon( @@ -311,8 +320,8 @@ class PublicationDetailScreen extends ConsumerWidget { icon: Icon(isStarted ? Icons.play_arrow : Icons.play_circle), label: Text( isStarted - ? (isCompleted ? 'Read Again' : 'Continue Reading') - : 'Start Reading', + ? (isCompleted ? l10n.publicationReadAgain : l10n.publicationContinueReading) + : l10n.publicationStartReading, ), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), @@ -323,7 +332,7 @@ class PublicationDetailScreen extends ConsumerWidget { OutlinedButton.icon( onPressed: () => _startFromBeginning(context, ref), icon: const Icon(Icons.restart_alt), - label: const Text('Start from Beginning'), + label: Text(l10n.publicationStartFromBeginning), ), ], ], @@ -447,28 +456,25 @@ class PublicationDetailScreen extends ConsumerWidget { return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } - String _formatDateTime(DateTime dateTime) { + String _formatDateTime(BuildContext context, DateTime dateTime) { + final l10n = AppLocalizations.of(context); final now = DateTime.now(); final difference = now.difference(dateTime); if (difference.inDays == 0) { if (difference.inHours == 0) { if (difference.inMinutes == 0) { - return 'Just now'; + return l10n.timeJustNow; } - return '${difference.inMinutes} min ago'; + return l10n.timeMinAgo(difference.inMinutes); } - return '${difference.inHours} hour${difference.inHours > 1 ? 's' : ''} ago'; + return l10n.timeHoursAgoLong(difference.inHours); } else if (difference.inDays == 1) { - return 'Yesterday'; + return l10n.timeYesterday; } else if (difference.inDays < 7) { - return '${difference.inDays} days ago'; + return l10n.timeDaysAgoLong(difference.inDays); } else { return _formatDate(dateTime); } } - - String _stripHtml(String html) { - return html.replaceAll(RegExp(r'<[^>]*>'), '').trim(); - } } diff --git a/lib/screens/reader/epub_reader_screen.dart b/lib/screens/reader/epub_reader_screen.dart index c02e979..6afbf5d 100644 --- a/lib/screens/reader/epub_reader_screen.dart +++ b/lib/screens/reader/epub_reader_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:flutter_epub_viewer/flutter_epub_viewer.dart'; import 'package:go_router/go_router.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/helpers/snackbar_helper.dart'; import 'package:worldhopper/models/enhanced_metadata.dart'; @@ -41,7 +42,7 @@ class _EpubReaderScreenState extends ConsumerState { EpubController? _epubController; bool _isLoading = true; double _loadingProgress = 0.0; - String _loadingStatus = 'Preparing...'; + String? _loadingStatus; String? _errorMessage; File? _epubFile; String? _initialCfi; @@ -75,10 +76,11 @@ class _EpubReaderScreenState extends ConsumerState { Future _loadEpub() async { try { + final l10n = AppLocalizations.of(context); setState(() { _isLoading = true; _loadingProgress = 0.0; - _loadingStatus = 'Preparing...'; + _loadingStatus = l10n.epubPreparing; _errorMessage = null; }); @@ -86,7 +88,7 @@ class _EpubReaderScreenState extends ConsumerState { if (server == null) { setState(() { - _errorMessage = 'Server not found'; + _errorMessage = l10n.readerServerNotFound; _isLoading = false; }); return; @@ -98,7 +100,7 @@ class _EpubReaderScreenState extends ConsumerState { if (isCached) { setState(() { - _loadingStatus = 'Loading from cache...'; + _loadingStatus = l10n.epubLoadingFromCache; _loadingProgress = 0.5; }); } @@ -108,18 +110,22 @@ class _EpubReaderScreenState extends ConsumerState { widget.entry, server, onProgress: (progress) { + if (!mounted) return; + final l10n = AppLocalizations.of(context); setState(() { _loadingProgress = progress * 0.8; // Reserve 0.8-1.0 for processing _loadingStatus = isCached - ? 'Loading from cache...' - : 'Downloading... ${(progress * 100).toInt()}%'; + ? l10n.epubLoadingFromCache + : l10n.epubDownloading((progress * 100).toInt()); }); }, ); + if (!mounted) return; + final l10nAfter = AppLocalizations.of(context); setState(() { _loadingProgress = 0.85; - _loadingStatus = 'Processing EPUB...'; + _loadingStatus = l10nAfter.epubProcessing; }); // Cache the publication @@ -148,9 +154,11 @@ class _EpubReaderScreenState extends ConsumerState { } } + if (!mounted) return; + final l10nFinal = AppLocalizations.of(context); setState(() { _loadingProgress = 1.0; - _loadingStatus = 'Opening reader...'; + _loadingStatus = l10nFinal.epubOpeningReader; _epubFile = file; _initialCfi = cfi; // Generate a unique key to force widget recreation and reset viewport @@ -158,8 +166,10 @@ class _EpubReaderScreenState extends ConsumerState { _isLoading = false; }); } catch (e) { + if (!mounted) return; + final l10n = AppLocalizations.of(context); setState(() { - _errorMessage = 'Error loading EPUB: $e'; + _errorMessage = l10n.epubErrorLoading(e.toString()); _isLoading = false; }); } @@ -209,7 +219,7 @@ class _EpubReaderScreenState extends ConsumerState { final percentage = location.progress; final currentPage = (percentage * 100).round(); const totalPages = 100; - + debugPrint('EPUB Progress - Location: ${location.progress * 100}%, Page: $currentPage/$totalPages, CFI: ${location.startCfi}'); // Store full location data as JSON @@ -251,7 +261,7 @@ class _EpubReaderScreenState extends ConsumerState { /// Build display settings based on orientation EpubDisplaySettings _buildDisplaySettings(Orientation orientation) { - debugPrint('🔧 _buildDisplaySettings: orientation=$orientation, width=${MediaQuery.of(context).size.width}'); + debugPrint('_buildDisplaySettings: orientation=$orientation, width=${MediaQuery.of(context).size.width}'); final brightness = Theme.of(context).brightness; final isDarkMode = brightness == Brightness.dark; @@ -291,7 +301,7 @@ class _EpubReaderScreenState extends ConsumerState { ? EpubSpread.always : EpubSpread.none; - debugPrint('🔧 _buildDisplaySettings: returning spread=$spread (width=$screenWidth)'); + debugPrint('_buildDisplaySettings: returning spread=$spread (width=$screenWidth)'); return EpubDisplaySettings( flow: EpubFlow.paginated, @@ -304,6 +314,8 @@ class _EpubReaderScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + if (_isLoading) { return Scaffold( backgroundColor: Colors.black, @@ -332,7 +344,7 @@ class _EpubReaderScreenState extends ConsumerState { ), const SizedBox(height: 16), Text( - _loadingStatus, + _loadingStatus ?? l10n.epubPreparing, style: const TextStyle( color: Colors.white, fontSize: 16, @@ -374,7 +386,7 @@ class _EpubReaderScreenState extends ConsumerState { const SizedBox(height: 16), ElevatedButton( onPressed: () => Navigator.pop(context), - child: const Text('Go Back'), + child: Text(l10n.epubGoBack), ), ], ), @@ -453,7 +465,7 @@ class _EpubReaderScreenState extends ConsumerState { final screenWidth = MediaQuery.of(context).size.width; const minWidthForDualPage = 768.0; final shouldUseDualPage = screenWidth >= minWidthForDualPage; - + return EpubViewer( key: ValueKey('$_viewerKey-${shouldUseDualPage ? "dual" : "single"}'), // Include spread in key to force recreation epubController: _epubController!, @@ -514,16 +526,17 @@ class _EpubReaderScreenState extends ConsumerState { /// Show search dialog void _showSearchDialog(BuildContext context) { + final l10n = AppLocalizations.of(context); String searchQuery = ''; showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Search'), + title: Text(l10n.epubSearch), content: TextField( autofocus: true, - decoration: const InputDecoration( - hintText: 'Enter search term...', + decoration: InputDecoration( + hintText: l10n.epubSearchHint, ), onChanged: (value) => searchQuery = value, onSubmitted: (value) { @@ -536,7 +549,7 @@ class _EpubReaderScreenState extends ConsumerState { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), + child: Text(l10n.commonCancel), ), TextButton( onPressed: () { @@ -545,7 +558,7 @@ class _EpubReaderScreenState extends ConsumerState { _performSearch(searchQuery); } }, - child: const Text('Search'), + child: Text(l10n.epubSearch), ), ], ), @@ -560,8 +573,10 @@ class _EpubReaderScreenState extends ConsumerState { if (!mounted) return; + final l10n = AppLocalizations.of(context); + if (results.isEmpty) { - context.showInfoSnackBar('No results found'); + context.showInfoSnackBar(l10n.epubNoResults); return; } @@ -573,7 +588,7 @@ class _EpubReaderScreenState extends ConsumerState { Padding( padding: const EdgeInsets.all(16.0), child: Text( - '${results.length} results for "$query"', + l10n.epubSearchResults(results.length, query), style: Theme.of(context).textTheme.titleMedium, ), ), @@ -602,7 +617,8 @@ class _EpubReaderScreenState extends ConsumerState { ); } catch (e) { if (!mounted) return; - context.showErrorSnackBar('Search failed: $e'); + final l10n = AppLocalizations.of(context); + context.showErrorSnackBar(l10n.epubSearchFailed(e.toString())); } } @@ -652,6 +668,7 @@ class _EpubReaderScreenState extends ConsumerState { /// Build custom app bar overlay Widget _buildCustomAppBar() { + final l10n = AppLocalizations.of(context); final topPadding = MediaQuery.of(context).padding.top; return Positioned( @@ -695,7 +712,7 @@ class _EpubReaderScreenState extends ConsumerState { icon: const Icon(Icons.arrow_back), color: Colors.white, onPressed: () => context.pop(), - tooltip: 'Back', + tooltip: l10n.epubBack, ), Expanded( child: Text( @@ -711,12 +728,12 @@ class _EpubReaderScreenState extends ConsumerState { ), // Offline indicator if (isOffline) - const Padding( - padding: EdgeInsets.only(right: 8), + Padding( + padding: const EdgeInsets.only(right: 8), child: Icon( Icons.cloud_off, color: Colors.redAccent, - semanticLabel: 'Offline', + semanticLabel: l10n.offlineSemanticLabel, ), ), // Chapter navigation button @@ -728,7 +745,7 @@ class _EpubReaderScreenState extends ConsumerState { _cancelAutoHideTimer(); _showChapterList(context); }, - tooltip: 'Chapters', + tooltip: l10n.epubChapters, ), // Search button IconButton( @@ -738,7 +755,7 @@ class _EpubReaderScreenState extends ConsumerState { _cancelAutoHideTimer(); _showSearchDialog(context); }, - tooltip: 'Search', + tooltip: l10n.epubSearch, ), ], ); diff --git a/lib/screens/reader/reader_screen.dart b/lib/screens/reader/reader_screen.dart index 35f0c82..eb8b487 100644 --- a/lib/screens/reader/reader_screen.dart +++ b/lib/screens/reader/reader_screen.dart @@ -5,6 +5,8 @@ import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; +import 'package:worldhopper/helpers/l10n_helpers.dart'; import 'package:worldhopper/models/enhanced_metadata.dart'; import 'package:worldhopper/models/opds_entry.dart'; import 'package:worldhopper/models/opds_stream_link.dart'; @@ -99,13 +101,15 @@ class _ReaderScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + if (!widget.entry.hasStreamLink) { return Scaffold( appBar: AppBar( - title: const Text('Error'), + title: Text(l10n.readerError), ), - body: const Center( - child: Text('This publication cannot be read (no stream link)'), + body: Center( + child: Text(l10n.readerNoStreamLink), ), ); } @@ -140,10 +144,10 @@ class _ReaderScreenState extends ConsumerState { serverAsync.when( data: (server) { if (server == null) { - return const Center( + return Center( child: Text( - 'Server not found', - style: TextStyle(color: Colors.white), + l10n.readerServerNotFound, + style: const TextStyle(color: Colors.white), ), ); } @@ -219,7 +223,7 @@ class _ReaderScreenState extends ConsumerState { ), error: (error, stack) => Center( child: Text( - 'Error loading server: $error', + l10n.readerErrorLoadingServer(error.toString()), style: const TextStyle(color: Colors.white), ), ), @@ -451,6 +455,8 @@ class _ReaderScreenState extends ConsumerState { ), child: Consumer( builder: (context, ref, _) { + final l10n = AppLocalizations.of(context); + // Watch connectivity state final connectivityState = ref.watch(connectivityStateProvider); @@ -496,12 +502,12 @@ class _ReaderScreenState extends ConsumerState { ), // Offline indicator if (isOffline) - const Padding( - padding: EdgeInsets.only(right: 8), + Padding( + padding: const EdgeInsets.only(right: 8), child: Icon( Icons.cloud_off, color: Colors.redAccent, - semanticLabel: 'Offline', + semanticLabel: l10n.offlineSemanticLabel, ), ), // Settings menu @@ -541,17 +547,15 @@ class _ReaderScreenState extends ConsumerState { } Widget _buildSettingsMenu(WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final globalMode = ref.watch(readingModeNotifierProvider); final seriesOverride = widget.feedUrl != null ? ref.watch(seriesReadingModeNotifierProvider(widget.feedUrl!)) : null; final readingMode = seriesOverride ?? globalMode; - final String readingModeLabel = switch (readingMode) { - ReadingMode.ltr => 'LTR', - ReadingMode.rtl => 'RTL', - ReadingMode.verticalContinuous => 'Vertical', - }; + final modeShortLabel = readingModeShortLabel(l10n, readingMode); final globalTwoPage = ref.watch(twoPageModeNotifierProvider); final seriesToPageOverride = widget.feedUrl != null @@ -590,7 +594,7 @@ class _ReaderScreenState extends ConsumerState { ? const Icon(Icons.check, size: 18) : null, onPressed: () => _setReadingMode(ref, ReadingMode.ltr), - child: const Text('Left to right'), + child: Text(l10n.readingModeLtr), ), MenuItemButton( leadingIcon: const Icon(Icons.arrow_back), @@ -598,7 +602,7 @@ class _ReaderScreenState extends ConsumerState { ? const Icon(Icons.check, size: 18) : null, onPressed: () => _setReadingMode(ref, ReadingMode.rtl), - child: const Text('Right to left'), + child: Text(l10n.readingModeRtl), ), MenuItemButton( leadingIcon: const Icon(Icons.swap_vert), @@ -606,10 +610,10 @@ class _ReaderScreenState extends ConsumerState { ? const Icon(Icons.check, size: 18) : null, onPressed: () => _setReadingMode(ref, ReadingMode.verticalContinuous), - child: const Text('Vertical scroll'), + child: Text(l10n.readingModeVertical), ), ], - child: Text('Reading direction ($readingModeLabel)'), + child: Text(l10n.readerReadingDirectionWithMode(modeShortLabel)), ), if (isPaged) MenuItemButton( @@ -623,7 +627,7 @@ class _ReaderScreenState extends ConsumerState { : TwoPageMode.auto; _setTwoPageMode(ref, newMode); }, - child: const Text('Two-page spread'), + child: Text(l10n.readersTwoPageSpread), ), if (isPaged) MenuItemButton( @@ -632,13 +636,14 @@ class _ReaderScreenState extends ConsumerState { ? const Icon(Icons.check, size: 18) : null, onPressed: () => _setCoverPage(ref, !firstPageIsCover), - child: const Text('First page is cover'), + child: Text(l10n.readersFirstPageIsCover), ), ], ); } Widget _buildBottomBar(BuildContext context, OPDSStreamLink streamLink) { + final l10n = AppLocalizations.of(context); final progress = (_currentPage + 1) / streamLink.pageCount; final globalMode = ref.watch(readingModeNotifierProvider); final seriesOverride = widget.feedUrl != null @@ -669,12 +674,12 @@ class _ReaderScreenState extends ConsumerState { if (spread.isPair) { final lo = spread.primaryPage + 1; final hi = spread.lastPage + 1; - pageIndicator = 'Pages $lo-$hi of ${streamLink.pageCount}'; + pageIndicator = l10n.readerPagesOf(lo, hi, streamLink.pageCount); } else { - pageIndicator = 'Page ${_currentPage + 1} of ${streamLink.pageCount}'; + pageIndicator = l10n.readerPageOf(_currentPage + 1, streamLink.pageCount); } } else { - pageIndicator = 'Page ${_currentPage + 1} of ${streamLink.pageCount}'; + pageIndicator = l10n.readerPageOf(_currentPage + 1, streamLink.pageCount); } // Select active controller for navigation buttons diff --git a/lib/screens/servers/add_server_screen.dart b/lib/screens/servers/add_server_screen.dart index 0ead799..0415a0d 100644 --- a/lib/screens/servers/add_server_screen.dart +++ b/lib/screens/servers/add_server_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:uuid/uuid.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/models/opds_server.dart'; import 'package:worldhopper/helpers/snackbar_helper.dart'; import 'package:worldhopper/providers/server_provider.dart'; @@ -35,9 +36,11 @@ class _AddServerScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Scaffold( appBar: AppBar( - title: const Text('Add Server'), + title: Text(l10n.addServerTitle), ), body: Form( key: _formKey, @@ -47,15 +50,15 @@ class _AddServerScreenState extends ConsumerState { // Name field TextFormField( controller: _nameController, - decoration: const InputDecoration( - labelText: 'Server Name', - hintText: 'My Library', - prefixIcon: Icon(Icons.label), + decoration: InputDecoration( + labelText: l10n.addServerNameLabel, + hintText: l10n.addServerNameHint, + prefixIcon: const Icon(Icons.label), ), textInputAction: TextInputAction.next, validator: (value) { if (value == null || value.trim().isEmpty) { - return 'Please enter a server name'; + return l10n.addServerValidationNameRequired; } return null; }, @@ -65,23 +68,23 @@ class _AddServerScreenState extends ConsumerState { // URL field TextFormField( controller: _urlController, - decoration: const InputDecoration( - labelText: 'Server URL', - hintText: 'https://example.com/opds', - prefixIcon: Icon(Icons.link), + decoration: InputDecoration( + labelText: l10n.addServerUrlLabel, + hintText: l10n.addServerUrlHint, + prefixIcon: const Icon(Icons.link), ), keyboardType: TextInputType.url, textInputAction: TextInputAction.next, validator: (value) { if (value == null || value.trim().isEmpty) { - return 'Please enter a server URL'; + return l10n.addServerValidationUrlRequired; } final uri = Uri.tryParse(value.trim()); if (uri == null || !uri.hasScheme || !uri.hasAuthority) { - return 'Please enter a valid URL'; + return l10n.addServerValidationUrlInvalid; } if (uri.scheme != 'http' && uri.scheme != 'https') { - return 'URL must start with http:// or https://'; + return l10n.addServerValidationUrlScheme; } return null; }, @@ -90,8 +93,8 @@ class _AddServerScreenState extends ConsumerState { // Authentication toggle SwitchListTile( - title: const Text('Requires Authentication'), - subtitle: const Text('Enable if server requires login'), + title: Text(l10n.addServerRequiresAuth), + subtitle: Text(l10n.addServerRequiresAuthSubtitle), value: _requiresAuth, onChanged: (value) { setState(() { @@ -109,14 +112,14 @@ class _AddServerScreenState extends ConsumerState { if (_requiresAuth) ...[ TextFormField( controller: _usernameController, - decoration: const InputDecoration( - labelText: 'Username', - prefixIcon: Icon(Icons.person), + decoration: InputDecoration( + labelText: l10n.addServerUsername, + prefixIcon: const Icon(Icons.person), ), textInputAction: TextInputAction.next, validator: (value) { if (_requiresAuth && (value == null || value.trim().isEmpty)) { - return 'Please enter a username'; + return l10n.addServerValidationUsernameRequired; } return null; }, @@ -125,7 +128,7 @@ class _AddServerScreenState extends ConsumerState { TextFormField( controller: _passwordController, decoration: InputDecoration( - labelText: 'Password', + labelText: l10n.addServerPassword, prefixIcon: const Icon(Icons.lock), suffixIcon: IconButton( icon: Icon( @@ -142,7 +145,7 @@ class _AddServerScreenState extends ConsumerState { textInputAction: TextInputAction.done, validator: (value) { if (_requiresAuth && (value == null || value.isEmpty)) { - return 'Please enter a password'; + return l10n.addServerValidationPasswordRequired; } return null; }, @@ -166,7 +169,7 @@ class _AddServerScreenState extends ConsumerState { const SizedBox(width: 12), Expanded( child: Text( - 'Enter the root URL of your OPDS server. Credentials will be securely stored on your device.', + l10n.addServerHelp, style: Theme.of(context).textTheme.bodySmall, ), ), @@ -185,7 +188,7 @@ class _AddServerScreenState extends ConsumerState { width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Add Server'), + : Text(l10n.addServerButton), ), ], ), @@ -203,6 +206,7 @@ class _AddServerScreenState extends ConsumerState { }); try { + final l10n = AppLocalizations.of(context); final url = _urlController.text.trim(); // Check if server already exists @@ -212,7 +216,7 @@ class _AddServerScreenState extends ConsumerState { if (exists) { if (mounted) { - context.showWarningSnackBar('A server with this URL already exists'); + context.showWarningSnackBar(l10n.addServerDuplicate); } setState(() { _isLoading = false; @@ -234,12 +238,13 @@ class _AddServerScreenState extends ConsumerState { await ref.read(serverNotifierProvider.notifier).addServer(server); if (mounted) { - context.showSuccessSnackBar('${server.name} added successfully'); + context.showSuccessSnackBar(l10n.addServerSuccess(server.name)); context.pop(); } } catch (e) { if (mounted) { - context.showErrorSnackBar('Error adding server: $e'); + final l10n = AppLocalizations.of(context); + context.showErrorSnackBar(l10n.addServerError(e.toString())); } } finally { if (mounted) { diff --git a/lib/screens/servers/edit_server_screen.dart b/lib/screens/servers/edit_server_screen.dart index 0b27f10..5942845 100644 --- a/lib/screens/servers/edit_server_screen.dart +++ b/lib/screens/servers/edit_server_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/helpers/snackbar_helper.dart'; import 'package:worldhopper/models/opds_server.dart'; import 'package:worldhopper/providers/server_provider.dart'; @@ -40,17 +41,18 @@ class _EditServerScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); final serverAsync = ref.watch(serverProvider(widget.serverId)); return Scaffold( appBar: AppBar( - title: const Text('Edit Server'), + title: Text(l10n.editServerTitle), ), body: serverAsync.when( data: (server) { if (server == null) { - return const Center( - child: Text('Server not found'), + return Center( + child: Text(l10n.editServerNotFound), ); } @@ -76,14 +78,14 @@ class _EditServerScreenState extends ConsumerState { // Name field TextFormField( controller: _nameController, - decoration: const InputDecoration( - labelText: 'Server Name', - prefixIcon: Icon(Icons.label), + decoration: InputDecoration( + labelText: l10n.addServerNameLabel, + prefixIcon: const Icon(Icons.label), ), textInputAction: TextInputAction.next, validator: (value) { if (value == null || value.trim().isEmpty) { - return 'Please enter a server name'; + return l10n.addServerValidationNameRequired; } return null; }, @@ -93,22 +95,22 @@ class _EditServerScreenState extends ConsumerState { // URL field TextFormField( controller: _urlController, - decoration: const InputDecoration( - labelText: 'Server URL', - prefixIcon: Icon(Icons.link), + decoration: InputDecoration( + labelText: l10n.addServerUrlLabel, + prefixIcon: const Icon(Icons.link), ), keyboardType: TextInputType.url, textInputAction: TextInputAction.next, validator: (value) { if (value == null || value.trim().isEmpty) { - return 'Please enter a server URL'; + return l10n.addServerValidationUrlRequired; } final uri = Uri.tryParse(value.trim()); if (uri == null || !uri.hasScheme || !uri.hasAuthority) { - return 'Please enter a valid URL'; + return l10n.addServerValidationUrlInvalid; } if (uri.scheme != 'http' && uri.scheme != 'https') { - return 'URL must start with http:// or https://'; + return l10n.addServerValidationUrlScheme; } return null; }, @@ -117,8 +119,8 @@ class _EditServerScreenState extends ConsumerState { // Authentication toggle SwitchListTile( - title: const Text('Requires Authentication'), - subtitle: const Text('Enable if server requires login'), + title: Text(l10n.addServerRequiresAuth), + subtitle: Text(l10n.addServerRequiresAuthSubtitle), value: _requiresAuth, onChanged: (value) { setState(() { @@ -136,15 +138,15 @@ class _EditServerScreenState extends ConsumerState { if (_requiresAuth) ...[ TextFormField( controller: _usernameController, - decoration: const InputDecoration( - labelText: 'Username', - prefixIcon: Icon(Icons.person), + decoration: InputDecoration( + labelText: l10n.addServerUsername, + prefixIcon: const Icon(Icons.person), ), textInputAction: TextInputAction.next, validator: (value) { if (_requiresAuth && (value == null || value.trim().isEmpty)) { - return 'Please enter a username'; + return l10n.addServerValidationUsernameRequired; } return null; }, @@ -153,7 +155,7 @@ class _EditServerScreenState extends ConsumerState { TextFormField( controller: _passwordController, decoration: InputDecoration( - labelText: 'Password', + labelText: l10n.addServerPassword, prefixIcon: const Icon(Icons.lock), suffixIcon: IconButton( icon: Icon( @@ -172,7 +174,7 @@ class _EditServerScreenState extends ConsumerState { textInputAction: TextInputAction.done, validator: (value) { if (_requiresAuth && (value == null || value.isEmpty)) { - return 'Please enter a password'; + return l10n.addServerValidationPasswordRequired; } return null; }, @@ -190,7 +192,7 @@ class _EditServerScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Server Information', + l10n.editServerInfo, style: Theme.of(context) .textTheme .titleSmall @@ -200,12 +202,12 @@ class _EditServerScreenState extends ConsumerState { ), const SizedBox(height: 8), _buildInfoRow( - 'Added', + l10n.editServerAdded, _formatDateTime(server.createdAt), ), if (server.lastSyncedAt != null) _buildInfoRow( - 'Last Synced', + l10n.editServerLastSynced, _formatDateTime(server.lastSyncedAt!), ), ], @@ -223,7 +225,7 @@ class _EditServerScreenState extends ConsumerState { width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Save Changes'), + : Text(l10n.editServerSaveButton), ), ], ), @@ -236,7 +238,7 @@ class _EditServerScreenState extends ConsumerState { children: [ const Icon(Icons.error_outline, size: 48, color: Colors.red), const SizedBox(height: 16), - Text('Error loading server: $error'), + Text(l10n.editServerErrorLoading(error.toString())), ], ), ), @@ -283,6 +285,8 @@ class _EditServerScreenState extends ConsumerState { }); try { + final l10n = AppLocalizations.of(context); + // Create updated server final updatedServer = _originalServer!.copyWith( name: _nameController.text.trim(), @@ -297,12 +301,13 @@ class _EditServerScreenState extends ConsumerState { .updateServer(updatedServer); if (mounted) { - context.showSuccessSnackBar('${updatedServer.name} updated successfully'); + context.showSuccessSnackBar(l10n.editServerSuccess(updatedServer.name)); context.pop(); } } catch (e) { if (mounted) { - context.showErrorSnackBar('Error updating server: $e'); + final l10n = AppLocalizations.of(context); + context.showErrorSnackBar(l10n.editServerError(e.toString())); } } finally { if (mounted) { diff --git a/lib/screens/servers/server_list_screen.dart b/lib/screens/servers/server_list_screen.dart index aba5928..574bb93 100644 --- a/lib/screens/servers/server_list_screen.dart +++ b/lib/screens/servers/server_list_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/helpers/snackbar_helper.dart'; import 'package:worldhopper/providers/server_provider.dart'; @@ -14,16 +15,17 @@ class ServerListScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); final serversAsync = ref.watch(serverListProvider); return Scaffold( - appBar: const WorldhopperAppBar( - title: Text('My Servers'), + appBar: WorldhopperAppBar( + title: Text(l10n.serverListTitle), ), body: serversAsync.when( data: (servers) { if (servers.isEmpty) { - return _buildEmptyState(context); + return _buildEmptyState(context, l10n); } return RefreshIndicator( @@ -48,7 +50,7 @@ class ServerListScreen extends ConsumerWidget { if (!isOnline) { // Block navigation when offline context.showInfoSnackBar( - 'You are offline. Connect to the internet to browse servers.', + l10n.serverListOffline, duration: const Duration(seconds: 3), ); return; @@ -63,13 +65,13 @@ class ServerListScreen extends ConsumerWidget { ); }, onDelete: () async { - final confirmed = await _confirmDelete(context, server.name); + final confirmed = await _confirmDelete(context, l10n, server.name); if (confirmed) { await ref .read(serverNotifierProvider.notifier) .deleteServer(server.id); if (context.mounted) { - context.showInfoSnackBar('${server.name} deleted'); + context.showInfoSnackBar(l10n.serverDeleted(server.name)); } } }, @@ -85,11 +87,11 @@ class ServerListScreen extends ConsumerWidget { children: [ const Icon(Icons.error_outline, size: 48, color: Colors.red), const SizedBox(height: 16), - Text('Error loading servers: $error'), + Text(l10n.serverListErrorLoading(error.toString())), const SizedBox(height: 16), ElevatedButton( onPressed: () => ref.invalidate(serverListProvider), - child: const Text('Retry'), + child: Text(l10n.serverListRetry), ), ], ), @@ -100,12 +102,12 @@ class ServerListScreen extends ConsumerWidget { context.push(AppConstants.routeAddServer); }, icon: const Icon(Icons.add), - label: const Text('Add Server'), + label: Text(l10n.serverListAddServer), ), ); } - Widget _buildEmptyState(BuildContext context) { + Widget _buildEmptyState(BuildContext context, AppLocalizations l10n) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -117,14 +119,14 @@ class ServerListScreen extends ConsumerWidget { ), const SizedBox(height: 24), Text( - 'No Servers Yet', + l10n.serverListEmpty, style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 8), Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( - 'Add your first OPDS server to start browsing and reading', + l10n.serverListEmptySubtitle, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context) @@ -139,23 +141,23 @@ class ServerListScreen extends ConsumerWidget { ); } - Future _confirmDelete(BuildContext context, String serverName) async { + Future _confirmDelete(BuildContext context, AppLocalizations l10n, String serverName) async { final result = await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Delete Server'), - content: Text('Are you sure you want to delete "$serverName"?'), + title: Text(l10n.serverDeleteTitle), + content: Text(l10n.serverDeleteConfirmation(serverName)), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), + child: Text(l10n.commonCancel), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, ), - child: const Text('Delete'), + child: Text(l10n.commonDelete), ), ], ), diff --git a/lib/screens/settings/about_screen.dart b/lib/screens/settings/about_screen.dart index 6151cfb..81eb6db 100644 --- a/lib/screens/settings/about_screen.dart +++ b/lib/screens/settings/about_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:worldhopper/providers/package_info_provider.dart'; import 'package:worldhopper/widgets/worldhopper_app_bar.dart'; @@ -12,11 +13,12 @@ class AboutScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); final packageInfoAsync = ref.watch(packageInfoProvider); return Scaffold( - appBar: const WorldhopperAppBar( - title: Text('About'), + appBar: WorldhopperAppBar( + title: Text(l10n.aboutTitle), ), body: ListView( children: [ @@ -27,17 +29,17 @@ class AboutScreen extends ConsumerWidget { children: [ ListTile( leading: const Icon(Icons.label_outline), - title: const Text('Version'), + title: Text(l10n.aboutVersion), subtitle: Text(packageInfoAsync.when( data: (info) => info.version, loading: () => '...', - error: (_, __) => 'unknown', + error: (_, __) => l10n.aboutVersionUnknown, )), ), const Divider(height: 1), ListTile( leading: const Icon(Icons.code), - title: const Text('Source code'), + title: Text(l10n.aboutSourceCode), subtitle: const Text(_repoUrl), trailing: const Icon(Icons.open_in_new), onTap: () => launchUrl(Uri.parse(_repoUrl), @@ -46,8 +48,8 @@ class AboutScreen extends ConsumerWidget { const Divider(height: 1), ListTile( leading: const Icon(Icons.bug_report_outlined), - title: const Text('Report an issue'), - subtitle: const Text('Open an issue on the repository'), + title: Text(l10n.aboutReportIssue), + subtitle: Text(l10n.aboutReportIssueSubtitle), trailing: const Icon(Icons.open_in_new), onTap: () => launchUrl(Uri.parse('$_repoUrl/issues'), mode: LaunchMode.externalApplication), diff --git a/lib/screens/settings/advanced_settings_screen.dart b/lib/screens/settings/advanced_settings_screen.dart index 841c6e7..c2ad7e3 100644 --- a/lib/screens/settings/advanced_settings_screen.dart +++ b/lib/screens/settings/advanced_settings_screen.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/helpers/snackbar_helper.dart'; import 'package:worldhopper/providers/developer_mode_provider.dart'; @@ -16,12 +17,13 @@ class AdvancedSettingsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); final packageInfoAsync = ref.watch(packageInfoProvider); final isDeveloperMode = ref.watch(developerModeNotifierProvider); return Scaffold( - appBar: const WorldhopperAppBar( - title: Text('Advanced'), + appBar: WorldhopperAppBar( + title: Text(l10n.advancedTitle), ), body: ListView( children: [ @@ -32,8 +34,8 @@ class AdvancedSettingsScreen extends ConsumerWidget { children: [ SwitchListTile( secondary: const Icon(Icons.developer_mode), - title: const Text('Developer mode'), - subtitle: const Text('Enable debugging tools'), + title: Text(l10n.advancedDeveloperMode), + subtitle: Text(l10n.advancedDeveloperModeSubtitle), value: isDeveloperMode, onChanged: (value) { ref @@ -45,8 +47,8 @@ class AdvancedSettingsScreen extends ConsumerWidget { const Divider(height: 1), ListTile( leading: const Icon(Icons.science_outlined), - title: const Text('Tests'), - subtitle: const Text('Preview UI components'), + title: Text(l10n.advancedTests), + subtitle: Text(l10n.advancedTestsSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.of(context).push( @@ -60,7 +62,7 @@ class AdvancedSettingsScreen extends ConsumerWidget { ], ), ), - _buildSectionHeader(context, 'Debugging info'), + _buildSectionHeader(context, l10n.advancedDebuggingInfo), Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: packageInfoAsync.when( @@ -71,22 +73,22 @@ class AdvancedSettingsScreen extends ConsumerWidget { children: [ ListTile( leading: const Icon(Icons.label_outline), - title: const Text('Version'), + title: Text(l10n.advancedVersion), subtitle: Text(info.version), ), ListTile( leading: const Icon(Icons.build_outlined), - title: const Text('Build number'), + title: Text(l10n.advancedBuildNumber), subtitle: Text(info.buildNumber), ), ListTile( leading: const Icon(Icons.inventory_2_outlined), - title: const Text('Package name'), + title: Text(l10n.advancedPackageName), subtitle: Text(info.packageName), ), - const ListTile( - leading: Icon(Icons.storage_outlined), - title: Text('Database version'), + ListTile( + leading: const Icon(Icons.storage_outlined), + title: Text(l10n.advancedDatabaseVersion), subtitle: Text('${AppConstants.databaseVersion}'), ), @@ -94,14 +96,13 @@ class AdvancedSettingsScreen extends ConsumerWidget { leading: Icon(Platform.isIOS ? Icons.phone_iphone : Icons.phone_android), - title: const Text('Platform'), + title: Text(l10n.advancedPlatform), subtitle: Text(platform), ), ListTile( leading: const Icon(Icons.copy), - title: const Text('Copy to clipboard'), - subtitle: - const Text('Copy all debug info for bug reports'), + title: Text(l10n.advancedCopyToClipboard), + subtitle: Text(l10n.advancedCopyToClipboardSubtitle), onTap: () { final debugInfo = StringBuffer() ..writeln( @@ -115,7 +116,7 @@ class AdvancedSettingsScreen extends ConsumerWidget { ..writeln('Platform: $platform'); Clipboard.setData( ClipboardData(text: debugInfo.toString().trim())); - context.showInfoSnackBar('Debug info copied to clipboard'); + context.showInfoSnackBar(l10n.advancedDebugInfoCopied); }, ), ], @@ -127,7 +128,7 @@ class AdvancedSettingsScreen extends ConsumerWidget { ), error: (error, _) => ListTile( leading: const Icon(Icons.error_outline), - title: const Text('Failed to load info'), + title: Text(l10n.advancedFailedToLoadInfo), subtitle: Text('$error'), ), ), diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index f02ab9b..77517b7 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/providers/theme_provider.dart'; import 'package:worldhopper/widgets/worldhopper_app_bar.dart'; @@ -9,22 +10,23 @@ class AppearanceSettingsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); final themeMode = ref.watch(themeNotifierProvider); return Scaffold( - appBar: const WorldhopperAppBar( - title: Text('Appearance'), + appBar: WorldhopperAppBar( + title: Text(l10n.appearanceTitle), ), body: ListView( children: [ - _buildSectionHeader(context, 'Theme'), + _buildSectionHeader(context, l10n.appearanceTheme), Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( children: [ ListTile( leading: const Icon(Icons.light_mode), - title: const Text('Light'), + title: Text(l10n.themeLight), trailing: Radio( value: ThemeMode.light, groupValue: themeMode, @@ -45,7 +47,7 @@ class AppearanceSettingsScreen extends ConsumerWidget { const Divider(height: 1), ListTile( leading: const Icon(Icons.dark_mode), - title: const Text('Dark'), + title: Text(l10n.themeDark), trailing: Radio( value: ThemeMode.dark, groupValue: themeMode, @@ -66,8 +68,8 @@ class AppearanceSettingsScreen extends ConsumerWidget { const Divider(height: 1), ListTile( leading: const Icon(Icons.brightness_auto), - title: const Text('System'), - subtitle: const Text('Follow device theme'), + title: Text(l10n.themeSystem), + subtitle: Text(l10n.themeSystemSubtitle), trailing: Radio( value: ThemeMode.system, groupValue: themeMode, diff --git a/lib/screens/settings/dev_tests_screen.dart b/lib/screens/settings/dev_tests_screen.dart index a1680d6..d20e357 100644 --- a/lib/screens/settings/dev_tests_screen.dart +++ b/lib/screens/settings/dev_tests_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/helpers/snackbar_helper.dart'; import 'package:worldhopper/widgets/worldhopper_app_bar.dart'; @@ -8,39 +9,41 @@ class DevTestsScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Scaffold( - appBar: const WorldhopperAppBar( - title: Text('Dev Tests'), + appBar: WorldhopperAppBar( + title: Text(l10n.devTestsTitle), ), body: ListView( children: [ - _buildSectionHeader(context, 'Snack Bars'), + _buildSectionHeader(context, l10n.devTestsSnackBars), Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( children: [ ListTile( leading: const Icon(Icons.check_circle, color: Colors.green), - title: const Text('Success'), - onTap: () => context.showSuccessSnackBar('This is a success message'), + title: Text(l10n.devTestsSuccess), + onTap: () => context.showSuccessSnackBar(l10n.devTestsSuccessMessage), ), const Divider(height: 1), ListTile( leading: Icon(Icons.error, color: Theme.of(context).colorScheme.error), - title: const Text('Error'), - onTap: () => context.showErrorSnackBar('This is an error message'), + title: Text(l10n.devTestsError), + onTap: () => context.showErrorSnackBar(l10n.devTestsErrorMessage), ), const Divider(height: 1), ListTile( leading: const Icon(Icons.warning, color: Colors.orange), - title: const Text('Warning'), - onTap: () => context.showWarningSnackBar('This is a warning message'), + title: Text(l10n.devTestsWarning), + onTap: () => context.showWarningSnackBar(l10n.devTestsWarningMessage), ), const Divider(height: 1), ListTile( leading: const Icon(Icons.info_outline), - title: const Text('Info'), - onTap: () => context.showInfoSnackBar('This is an info message'), + title: Text(l10n.devTestsInfo), + onTap: () => context.showInfoSnackBar(l10n.devTestsInfoMessage), ), ], ), diff --git a/lib/screens/settings/filter_quality_settings_screen.dart b/lib/screens/settings/filter_quality_settings_screen.dart index 19f3b88..3e4f691 100644 --- a/lib/screens/settings/filter_quality_settings_screen.dart +++ b/lib/screens/settings/filter_quality_settings_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/providers/filter_quality_provider.dart'; import 'package:worldhopper/widgets/worldhopper_app_bar.dart'; @@ -9,11 +10,35 @@ class FilterQualitySettingsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); final filterQuality = ref.watch(filterQualityNotifierProvider); + final options = { + FilterQuality.none: _FilterQualityOption( + label: l10n.filterQualityNone, + description: l10n.filterQualityNoneDescription, + icon: Icons.speed, + ), + FilterQuality.low: _FilterQualityOption( + label: l10n.filterQualityLow, + description: l10n.filterQualityLowDescription, + icon: Icons.blur_linear, + ), + FilterQuality.medium: _FilterQualityOption( + label: l10n.filterQualityMedium, + description: l10n.filterQualityMediumDescription, + icon: Icons.tune, + ), + FilterQuality.high: _FilterQualityOption( + label: l10n.filterQualityHigh, + description: l10n.filterQualityHighDescription, + icon: Icons.high_quality, + ), + }; + return Scaffold( - appBar: const WorldhopperAppBar( - title: Text('Filter Quality'), + appBar: WorldhopperAppBar( + title: Text(l10n.filterQualityTitle), ), body: ListView( children: [ @@ -22,8 +47,8 @@ class FilterQualitySettingsScreen extends ConsumerWidget { margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( children: [ - for (final entry in _filterQualityOptions.entries) ...[ - if (entry.key != _filterQualityOptions.keys.first) + for (final entry in options.entries) ...[ + if (entry.key != options.keys.first) const Divider(height: 1), ListTile( leading: Icon(entry.value.icon), @@ -53,9 +78,7 @@ class FilterQualitySettingsScreen extends ConsumerWidget { Padding( padding: const EdgeInsets.fromLTRB(32, 16, 32, 16), child: Text( - 'Controls how images are scaled when a page is zoomed out to fit ' - 'the screen. Higher quality makes text and fine details sharper ' - 'but uses more GPU resources. Medium is recommended for most devices.', + l10n.filterQualityHelp, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -78,26 +101,3 @@ class _FilterQualityOption { required this.icon, }); } - -const _filterQualityOptions = { - FilterQuality.none: _FilterQualityOption( - label: 'None', - description: 'Fastest, may look pixelated', - icon: Icons.speed, - ), - FilterQuality.low: _FilterQualityOption( - label: 'Low', - description: 'Bilinear interpolation', - icon: Icons.blur_linear, - ), - FilterQuality.medium: _FilterQualityOption( - label: 'Medium', - description: 'Bilinear with mipmaps, good balance', - icon: Icons.tune, - ), - FilterQuality.high: _FilterQualityOption( - label: 'High', - description: 'Bicubic interpolation, best quality', - icon: Icons.high_quality, - ), -}; diff --git a/lib/screens/settings/language_settings_screen.dart b/lib/screens/settings/language_settings_screen.dart new file mode 100644 index 0000000..4c55aad --- /dev/null +++ b/lib/screens/settings/language_settings_screen.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; +import 'package:worldhopper/providers/locale_provider.dart'; +import 'package:worldhopper/widgets/worldhopper_app_bar.dart'; + +/// Screen for selecting the app language +class LanguageSettingsScreen extends ConsumerWidget { + const LanguageSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final currentLocale = ref.watch(localeNotifierProvider); + + return Scaffold( + appBar: WorldhopperAppBar( + title: Text(l10n.languageTitle), + ), + body: ListView( + children: [ + const SizedBox(height: 8), + Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + children: [ + // System default option + ListTile( + leading: const Icon(Icons.language), + title: Text(l10n.languageSystem), + subtitle: Text(l10n.languageSystemSubtitle), + trailing: Radio( + value: null, + groupValue: currentLocale, + onChanged: (value) { + ref + .read(localeNotifierProvider.notifier) + .setLocale(null); + }, + ), + onTap: () { + ref + .read(localeNotifierProvider.notifier) + .setLocale(null); + }, + ), + // Supported locales + for (final locale in AppLocalizations.supportedLocales) ...[ + const Divider(height: 1), + ListTile( + title: Text(_localeDisplayName(locale)), + trailing: Radio( + value: locale, + groupValue: currentLocale, + onChanged: (value) { + ref + .read(localeNotifierProvider.notifier) + .setLocale(value); + }, + ), + onTap: () { + ref + .read(localeNotifierProvider.notifier) + .setLocale(locale); + }, + ), + ], + ], + ), + ), + const SizedBox(height: 16), + ], + ), + ); + } + + String _localeDisplayName(Locale locale) { + // Map locale codes to their native display names + const localeNames = { + 'en': 'English', + 'es': 'Espa\u00f1ol', + 'fr': 'Fran\u00e7ais', + 'de': 'Deutsch', + 'it': 'Italiano', + 'pt': 'Portugu\u00eas', + 'ja': '\u65e5\u672c\u8a9e', + 'ko': '\ud55c\uad6d\uc5b4', + 'zh': '\u4e2d\u6587', + }; + + return localeNames[locale.languageCode] ?? locale.languageCode; + } +} diff --git a/lib/screens/settings/precache_pages_settings_screen.dart b/lib/screens/settings/precache_pages_settings_screen.dart index 0be2f15..d4f6f59 100644 --- a/lib/screens/settings/precache_pages_settings_screen.dart +++ b/lib/screens/settings/precache_pages_settings_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/providers/precache_pages_provider.dart'; import 'package:worldhopper/widgets/worldhopper_app_bar.dart'; @@ -9,11 +10,45 @@ class PrecachePagesSettingsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); final precachePages = ref.watch(precachePagesNotifierProvider); + final options = { + 0: _PrecachePagesOption( + label: l10n.precachePagesOff, + description: l10n.precachePagesOffDescription, + icon: Icons.block, + ), + 1: _PrecachePagesOption( + label: l10n.precachePages1, + description: l10n.precachePages1Description, + icon: Icons.looks_one, + ), + 2: _PrecachePagesOption( + label: l10n.precachePages2, + description: l10n.precachePages2Description, + icon: Icons.looks_two, + ), + 3: _PrecachePagesOption( + label: l10n.precachePages3, + description: l10n.precachePages3Description, + icon: Icons.looks_3, + ), + 4: _PrecachePagesOption( + label: l10n.precachePages4, + description: l10n.precachePages4Description, + icon: Icons.looks_4, + ), + 5: _PrecachePagesOption( + label: l10n.precachePages5, + description: l10n.precachePages5Description, + icon: Icons.looks_5, + ), + }; + return Scaffold( - appBar: const WorldhopperAppBar( - title: Text('Pre-cache Pages'), + appBar: WorldhopperAppBar( + title: Text(l10n.precachePagesTitle), ), body: ListView( children: [ @@ -22,8 +57,8 @@ class PrecachePagesSettingsScreen extends ConsumerWidget { margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( children: [ - for (final entry in _precachePagesOptions.entries) ...[ - if (entry.key != _precachePagesOptions.keys.first) + for (final entry in options.entries) ...[ + if (entry.key != options.keys.first) const Divider(height: 1), ListTile( leading: Icon(entry.value.icon), @@ -53,9 +88,7 @@ class PrecachePagesSettingsScreen extends ConsumerWidget { Padding( padding: const EdgeInsets.fromLTRB(32, 16, 32, 16), child: Text( - 'Controls how many upcoming pages are pre-loaded in the background ' - 'while reading. Higher values make swiping feel more seamless but ' - 'use more bandwidth and memory.', + l10n.precachePagesHelp, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -78,36 +111,3 @@ class _PrecachePagesOption { required this.icon, }); } - -const _precachePagesOptions = { - 0: _PrecachePagesOption( - label: 'Off', - description: 'Pages load on demand', - icon: Icons.block, - ), - 1: _PrecachePagesOption( - label: '1 page', - description: 'Minimal pre-loading', - icon: Icons.looks_one, - ), - 2: _PrecachePagesOption( - label: '2 pages', - description: 'Light pre-loading', - icon: Icons.looks_two, - ), - 3: _PrecachePagesOption( - label: '3 pages', - description: 'Balanced, recommended', - icon: Icons.looks_3, - ), - 4: _PrecachePagesOption( - label: '4 pages', - description: 'Aggressive pre-loading', - icon: Icons.looks_4, - ), - 5: _PrecachePagesOption( - label: '5 pages', - description: 'Maximum pre-loading, uses more bandwidth', - icon: Icons.looks_5, - ), -}; diff --git a/lib/screens/settings/readers_settings_screen.dart b/lib/screens/settings/readers_settings_screen.dart index c573bd4..6145cec 100644 --- a/lib/screens/settings/readers_settings_screen.dart +++ b/lib/screens/settings/readers_settings_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; +import 'package:worldhopper/helpers/l10n_helpers.dart'; import 'package:worldhopper/providers/filter_quality_provider.dart'; import 'package:worldhopper/providers/precache_pages_provider.dart'; import 'package:worldhopper/providers/reading_mode_provider.dart'; @@ -14,30 +16,9 @@ import 'package:worldhopper/widgets/worldhopper_app_bar.dart'; class ReadersSettingsScreen extends ConsumerWidget { const ReadersSettingsScreen({super.key}); - static const _readingModeLabels = { - ReadingMode.ltr: 'Left to right', - ReadingMode.rtl: 'Right to left', - ReadingMode.verticalContinuous: 'Vertical scroll', - }; - - static const _filterQualityLabels = { - FilterQuality.none: 'None', - FilterQuality.low: 'Low', - FilterQuality.medium: 'Medium', - FilterQuality.high: 'High', - }; - - static const _precachePagesLabels = { - 0: 'Off', - 1: '1 page', - 2: '2 pages', - 3: '3 pages', - 4: '4 pages', - 5: '5 pages', - }; - @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); final readingMode = ref.watch(readingModeNotifierProvider); final filterQuality = ref.watch(filterQualityNotifierProvider); final precachePages = ref.watch(precachePagesNotifierProvider); @@ -45,21 +26,20 @@ class ReadersSettingsScreen extends ConsumerWidget { final firstPageIsCover = ref.watch(coverPageNotifierProvider); return Scaffold( - appBar: const WorldhopperAppBar( - title: Text('Readers'), + appBar: WorldhopperAppBar( + title: Text(l10n.readersTitle), ), body: ListView( children: [ - _buildSectionHeader(context, 'Image Reader'), + _buildSectionHeader(context, l10n.readersImageReaderSection), Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( children: [ ListTile( leading: const Icon(Icons.chrome_reader_mode_outlined), - title: const Text('Reading direction'), - subtitle: - Text(_readingModeLabels[readingMode] ?? 'Left to right'), + title: Text(l10n.readersReadingDirection), + subtitle: Text(readingModeLabel(l10n, readingMode)), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.of(context).push( @@ -72,9 +52,8 @@ class ReadersSettingsScreen extends ConsumerWidget { const Divider(height: 1), ListTile( leading: const Icon(Icons.high_quality_outlined), - title: const Text('Filter quality'), - subtitle: - Text(_filterQualityLabels[filterQuality] ?? 'Medium'), + title: Text(l10n.readersFilterQuality), + subtitle: Text(filterQualityLabel(l10n, filterQuality)), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.of(context).push( @@ -87,9 +66,8 @@ class ReadersSettingsScreen extends ConsumerWidget { const Divider(height: 1), ListTile( leading: const Icon(Icons.cached_outlined), - title: const Text('Pre-cache pages'), - subtitle: Text( - _precachePagesLabels[precachePages] ?? '$precachePages pages'), + title: Text(l10n.readersPrecachePages), + subtitle: Text(precachePagesLabel(l10n, precachePages)), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.of(context).push( @@ -102,9 +80,8 @@ class ReadersSettingsScreen extends ConsumerWidget { const Divider(height: 1), SwitchListTile( secondary: const Icon(Icons.auto_stories), - title: const Text('Two-page spread'), - subtitle: const Text( - 'Show two pages side by side on wide screens'), + title: Text(l10n.readersTwoPageSpread), + subtitle: Text(l10n.readersTwoPageSpreadSubtitle), value: twoPageMode == TwoPageMode.auto, onChanged: (enabled) { ref.read(twoPageModeNotifierProvider.notifier) @@ -115,9 +92,8 @@ class ReadersSettingsScreen extends ConsumerWidget { const Divider(height: 1), SwitchListTile( secondary: const Icon(Icons.looks_one), - title: const Text('First page is cover'), - subtitle: const Text( - 'Show the first page alone in two-page spread'), + title: Text(l10n.readersFirstPageIsCover), + subtitle: Text(l10n.readersFirstPageIsCoverSubtitle), value: firstPageIsCover, onChanged: (value) { ref.read(coverPageNotifierProvider.notifier) diff --git a/lib/screens/settings/reading_mode_settings_screen.dart b/lib/screens/settings/reading_mode_settings_screen.dart index 2549d5a..a719d4b 100644 --- a/lib/screens/settings/reading_mode_settings_screen.dart +++ b/lib/screens/settings/reading_mode_settings_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/providers/reading_mode_provider.dart'; import 'package:worldhopper/widgets/worldhopper_app_bar.dart'; @@ -9,11 +10,30 @@ class ReadingModeSettingsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); final readingMode = ref.watch(readingModeNotifierProvider); + final options = { + ReadingMode.ltr: _ReadingModeOption( + label: l10n.readingModeLtr, + description: l10n.readingModeLtrDescription, + icon: Icons.arrow_forward, + ), + ReadingMode.rtl: _ReadingModeOption( + label: l10n.readingModeRtl, + description: l10n.readingModeRtlDescription, + icon: Icons.arrow_back, + ), + ReadingMode.verticalContinuous: _ReadingModeOption( + label: l10n.readingModeVertical, + description: l10n.readingModeVerticalDescription, + icon: Icons.swap_vert, + ), + }; + return Scaffold( - appBar: const WorldhopperAppBar( - title: Text('Reading Direction'), + appBar: WorldhopperAppBar( + title: Text(l10n.readingDirectionTitle), ), body: ListView( children: [ @@ -22,8 +42,8 @@ class ReadingModeSettingsScreen extends ConsumerWidget { margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( children: [ - for (final entry in _readingModeOptions.entries) ...[ - if (entry.key != _readingModeOptions.keys.first) + for (final entry in options.entries) ...[ + if (entry.key != options.keys.first) const Divider(height: 1), ListTile( leading: Icon(entry.value.icon), @@ -53,9 +73,7 @@ class ReadingModeSettingsScreen extends ConsumerWidget { Padding( padding: const EdgeInsets.fromLTRB(32, 16, 32, 16), child: Text( - 'Controls the reading direction for the image reader. ' - 'Left to right is standard for Western comics, right to left ' - 'for manga, and vertical scroll for webtoons.', + l10n.readingDirectionHelp, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -78,21 +96,3 @@ class _ReadingModeOption { required this.icon, }); } - -const _readingModeOptions = { - ReadingMode.ltr: _ReadingModeOption( - label: 'Left to right', - description: 'Standard Western reading order', - icon: Icons.arrow_forward, - ), - ReadingMode.rtl: _ReadingModeOption( - label: 'Right to left', - description: 'Manga-style reading order', - icon: Icons.arrow_back, - ), - ReadingMode.verticalContinuous: _ReadingModeOption( - label: 'Vertical scroll', - description: 'Continuous webtoon-style scrolling', - icon: Icons.swap_vert, - ), -}; diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index f5d8cad..b6e5d92 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/screens/settings/about_screen.dart'; import 'package:worldhopper/screens/settings/advanced_settings_screen.dart'; import 'package:worldhopper/screens/settings/appearance_settings_screen.dart'; +import 'package:worldhopper/screens/settings/language_settings_screen.dart'; import 'package:worldhopper/screens/settings/readers_settings_screen.dart'; import 'package:worldhopper/widgets/worldhopper_app_bar.dart'; @@ -12,9 +14,11 @@ class SettingsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + return Scaffold( - appBar: const WorldhopperAppBar( - title: Text('Settings'), + appBar: WorldhopperAppBar( + title: Text(l10n.settingsTitle), ), body: ListView( children: [ @@ -25,8 +29,8 @@ class SettingsScreen extends ConsumerWidget { children: [ ListTile( leading: const Icon(Icons.palette_outlined), - title: const Text('Appearance'), - subtitle: const Text('Display options'), + title: Text(l10n.settingsAppearance), + subtitle: Text(l10n.settingsAppearanceSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.of(context).push( @@ -37,10 +41,24 @@ class SettingsScreen extends ConsumerWidget { }, ), const Divider(height: 1), + ListTile( + leading: const Icon(Icons.language_outlined), + title: Text(l10n.settingsLanguage), + subtitle: Text(l10n.settingsLanguageSubtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const LanguageSettingsScreen(), + ), + ); + }, + ), + const Divider(height: 1), ListTile( leading: const Icon(Icons.menu_book_outlined), - title: const Text('Readers'), - subtitle: const Text('Reader options'), + title: Text(l10n.settingsReaders), + subtitle: Text(l10n.settingsReadersSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.of(context).push( @@ -53,8 +71,8 @@ class SettingsScreen extends ConsumerWidget { const Divider(height: 1), ListTile( leading: const Icon(Icons.tune_outlined), - title: const Text('Advanced'), - subtitle: const Text('Developer and debugging'), + title: Text(l10n.settingsAdvanced), + subtitle: Text(l10n.settingsAdvancedSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.of(context).push( @@ -67,8 +85,8 @@ class SettingsScreen extends ConsumerWidget { const Divider(height: 1), ListTile( leading: const Icon(Icons.info_outline), - title: const Text('About'), - subtitle: const Text('Worldhopper'), + title: Text(l10n.settingsAbout), + subtitle: Text(l10n.settingsAboutSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.of(context).push( diff --git a/lib/screens/shell/main_shell_screen.dart b/lib/screens/shell/main_shell_screen.dart index 3650e07..8d5aaa7 100644 --- a/lib/screens/shell/main_shell_screen.dart +++ b/lib/screens/shell/main_shell_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/providers/navigation_provider.dart'; /// Shell widget that provides the bottom navigation bar @@ -50,26 +51,28 @@ class _MainShellScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Scaffold( body: widget.navigationShell, bottomNavigationBar: NavigationBar( selectedIndex: widget.navigationShell.currentIndex, onDestinationSelected: _onDestinationSelected, - destinations: const [ + destinations: [ NavigationDestination( - icon: Icon(Icons.auto_stories_outlined), - selectedIcon: Icon(Icons.auto_stories), - label: 'Library', + icon: const Icon(Icons.auto_stories_outlined), + selectedIcon: const Icon(Icons.auto_stories), + label: l10n.navLibrary, ), NavigationDestination( - icon: Icon(Icons.dns_outlined), - selectedIcon: Icon(Icons.dns), - label: 'Servers', + icon: const Icon(Icons.dns_outlined), + selectedIcon: const Icon(Icons.dns), + label: l10n.navServers, ), NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: 'Settings', + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + label: l10n.navSettings, ), ], ), diff --git a/lib/widgets/next_in_series_overlay.dart b/lib/widgets/next_in_series_overlay.dart index 4abb94d..2cf96ac 100644 --- a/lib/widgets/next_in_series_overlay.dart +++ b/lib/widgets/next_in_series_overlay.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/models/opds_entry.dart'; import 'package:worldhopper/providers/connectivity_provider.dart'; @@ -81,7 +82,7 @@ class _NextInSeriesOverlayState extends ConsumerState { onColor: Theme.of(context).colorScheme.onPrimaryContainer, onTap: () => _navigateToNext(context, nextEntry), icon: Icons.skip_next, - label: 'Next', + label: AppLocalizations.of(context).nextInSeriesNext, title: nextEntry.title, trailingIcon: Icons.arrow_forward, ); @@ -92,6 +93,7 @@ class _NextInSeriesOverlayState extends ConsumerState { // --------------------------------------------------------------------------- Widget _buildFinishedOverlay(BuildContext context) { + final l10n = AppLocalizations.of(context); final connectivityState = ref.watch(connectivityStateProvider); final isOnline = connectivityState.whenOrNull( data: (online) => online, @@ -104,8 +106,8 @@ class _NextInSeriesOverlayState extends ConsumerState { onColor: Theme.of(context).colorScheme.onSecondaryContainer, onTap: () => _navigateBack(context, isOnline), icon: Icons.check_circle_outline, - label: 'Finished', - title: isOnline ? 'Back to details' : 'Back to library', + label: l10n.nextInSeriesFinished, + title: isOnline ? l10n.nextInSeriesBackToDetails : l10n.nextInSeriesBackToLibrary, trailingIcon: Icons.arrow_back, ); } diff --git a/lib/widgets/publication_card.dart b/lib/widgets/publication_card.dart index f7c78b5..01e7b3c 100644 --- a/lib/widgets/publication_card.dart +++ b/lib/widgets/publication_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/models/opds_entry.dart'; /// Card widget displaying a publication or navigation entry @@ -17,6 +18,8 @@ class PublicationCard extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Card( clipBehavior: Clip.antiAlias, child: InkWell( @@ -68,7 +71,7 @@ class PublicationCard extends StatelessWidget { ), const SizedBox(width: 4), Text( - '${entry.streamLink!.pageCount} pages', + l10n.publicationPages(entry.streamLink!.pageCount), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.primary, @@ -88,7 +91,7 @@ class PublicationCard extends StatelessWidget { ), const SizedBox(width: 4), Text( - 'Collection', + l10n.publicationCollection, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: diff --git a/lib/widgets/recently_read_card.dart b/lib/widgets/recently_read_card.dart index de6d5cd..99cb551 100644 --- a/lib/widgets/recently_read_card.dart +++ b/lib/widgets/recently_read_card.dart @@ -5,6 +5,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/helpers/snackbar_helper.dart'; import 'package:worldhopper/models/reading_progress.dart'; @@ -25,6 +26,8 @@ class RecentlyReadCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + // Load cached publication final publicationAsync = ref.watch( cachedPublicationProvider(progress.serverId, progress.publicationId), @@ -39,7 +42,7 @@ class RecentlyReadCard extends ConsumerWidget { child: Padding( padding: const EdgeInsets.all(16.0), child: Text( - 'Publication not found', + l10n.recentlyReadPublicationNotFound, style: Theme.of(context).textTheme.bodySmall, ), ), @@ -120,7 +123,7 @@ class RecentlyReadCard extends ConsumerWidget { // Progress and date Text( - '${((progress.progressPercentage) * 100).toStringAsFixed(0)}% • ${_formatDate(progress.lastReadAt)}', + '${((progress.progressPercentage) * 100).toStringAsFixed(0)}% \u2022 ${_formatDate(context, progress.lastReadAt)}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context) .colorScheme @@ -144,7 +147,7 @@ class RecentlyReadCard extends ConsumerWidget { child: Padding( padding: const EdgeInsets.all(16.0), child: Text( - 'Error loading publication', + l10n.recentlyReadErrorLoading, style: Theme.of(context).textTheme.bodySmall, ), ), @@ -206,6 +209,8 @@ class RecentlyReadCard extends ConsumerWidget { WidgetRef ref, dynamic publication, ) async { + final l10n = AppLocalizations.of(context); + // Convert to OPDSEntry final entry = publication.toOPDSEntry(); @@ -226,7 +231,7 @@ class RecentlyReadCard extends ConsumerWidget { // Block EPUBs that aren't cached if (!context.mounted) return; context.showInfoSnackBar( - 'This EPUB is not cached. Connect to internet to download.', + l10n.recentlyReadOfflineEpubNotCached, duration: const Duration(seconds: 3), ); return; // Block navigation @@ -234,7 +239,7 @@ class RecentlyReadCard extends ConsumerWidget { // Warn for OPDS-PS but allow navigation if (!context.mounted) return; context.showInfoSnackBar( - 'You are offline. Pages may not load.', + l10n.recentlyReadOfflinePagesWarning, duration: const Duration(seconds: 3), ); // Continue to navigation @@ -267,7 +272,7 @@ class RecentlyReadCard extends ConsumerWidget { } else { // No valid link found context.showInfoSnackBar( - 'Unsupported format', + l10n.recentlyReadUnsupportedFormat, duration: const Duration(seconds: 2), ); } @@ -279,6 +284,7 @@ class RecentlyReadCard extends ConsumerWidget { LongPressStartDetails details, dynamic publication, ) async { + final l10n = AppLocalizations.of(context); final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox; final RelativeRect position = RelativeRect.fromRect( @@ -292,13 +298,13 @@ class RecentlyReadCard extends ConsumerWidget { if (isDeveloperMode) { items.add( - const PopupMenuItem( + PopupMenuItem( value: 'debug_raw_data', child: Row( children: [ - Icon(Icons.data_object, size: 20), - SizedBox(width: 8), - Text('View raw data'), + const Icon(Icons.data_object, size: 20), + const SizedBox(width: 8), + Text(l10n.recentlyReadViewRawData), ], ), ), @@ -313,7 +319,7 @@ class RecentlyReadCard extends ConsumerWidget { Icon(Icons.delete, color: Theme.of(context).colorScheme.error, size: 20), const SizedBox(width: 8), Text( - 'Remove from Recently Read', + l10n.recentlyReadRemove, style: TextStyle(color: Theme.of(context).colorScheme.error), ), ], @@ -335,25 +341,23 @@ class RecentlyReadCard extends ConsumerWidget { } Future _confirmRemoval(BuildContext context, String title) async { + final l10n = AppLocalizations.of(context); final result = await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Remove from Recently Read'), - content: Text( - 'Remove "$title" from your reading history?\n\n' - 'This will not delete the book from your library.', - ), + title: Text(l10n.recentlyReadRemoveTitle), + content: Text(l10n.recentlyReadRemoveConfirmation(title)), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), + child: Text(l10n.commonCancel), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, ), - child: const Text('Remove'), + child: Text(l10n.commonRemove), ), ], ), @@ -377,20 +381,24 @@ class RecentlyReadCard extends ConsumerWidget { if (!context.mounted) return; + final l10n = AppLocalizations.of(context); + // Invalidate providers to refresh UI ref.invalidate(recentlyReadProvider); ref.invalidate(inProgressCountProvider); ref.invalidate(completedCountProvider); - context.showInfoSnackBar('Removed from Recently Read'); + context.showInfoSnackBar(l10n.recentlyReadRemoved); } catch (e) { if (!context.mounted) return; - context.showErrorSnackBar('Failed to remove: $e'); + final l10n = AppLocalizations.of(context); + context.showErrorSnackBar(l10n.recentlyReadRemoveFailed(e.toString())); } } void _showRawDataDialog(BuildContext context, dynamic publication) { + final l10n = AppLocalizations.of(context); const encoder = JsonEncoder.withIndent(' '); final progressJson = encoder.convert(progress.toJson()); final publicationJson = encoder.convert(publication.toJson()); @@ -400,7 +408,7 @@ class RecentlyReadCard extends ConsumerWidget { builder: (context) => Dialog.fullscreen( child: Scaffold( appBar: AppBar( - title: const Text('Raw Data'), + title: Text(l10n.recentlyReadRawData), leading: IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), @@ -421,22 +429,23 @@ class RecentlyReadCard extends ConsumerWidget { ); } - String _formatDate(DateTime date) { + String _formatDate(BuildContext context, DateTime date) { + final l10n = AppLocalizations.of(context); final now = DateTime.now(); final difference = now.difference(date); if (difference.inDays == 0) { - return 'Today'; + return l10n.timeToday; } else if (difference.inDays == 1) { - return 'Yesterday'; + return l10n.timeYesterday; } else if (difference.inDays < 7) { - return '${difference.inDays}d ago'; + return l10n.timeDaysAgo(difference.inDays); } else if (difference.inDays < 30) { - return '${(difference.inDays / 7).floor()}w ago'; + return l10n.timeWeeksAgo((difference.inDays / 7).floor()); } else if (difference.inDays < 365) { - return '${(difference.inDays / 30).floor()}mo ago'; + return l10n.timeMonthsAgo((difference.inDays / 30).floor()); } else { - return '${(difference.inDays / 365).floor()}y ago'; + return l10n.timeYearsAgo((difference.inDays / 365).floor()); } } } diff --git a/lib/widgets/server_card.dart b/lib/widgets/server_card.dart index caed9b8..3df4f95 100644 --- a/lib/widgets/server_card.dart +++ b/lib/widgets/server_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/models/opds_server.dart'; /// Card widget displaying an OPDS server @@ -18,6 +19,8 @@ class ServerCard extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Card( margin: const EdgeInsets.only(bottom: 12), child: InkWell( @@ -84,23 +87,23 @@ class ServerCard extends StatelessWidget { } }, itemBuilder: (context) => [ - const PopupMenuItem( + PopupMenuItem( value: 'edit', child: Row( children: [ - Icon(Icons.edit), - SizedBox(width: 8), - Text('Edit'), + const Icon(Icons.edit), + const SizedBox(width: 8), + Text(l10n.serverCardEdit), ], ), ), - const PopupMenuItem( + PopupMenuItem( value: 'delete', child: Row( children: [ - Icon(Icons.delete, color: Colors.red), - SizedBox(width: 8), - Text('Delete', style: TextStyle(color: Colors.red)), + const Icon(Icons.delete, color: Colors.red), + const SizedBox(width: 8), + Text(l10n.serverCardDelete, style: const TextStyle(color: Colors.red)), ], ), ), @@ -127,7 +130,7 @@ class ServerCard extends StatelessWidget { ), const SizedBox(width: 4), Text( - 'Authenticated', + l10n.serverCardAuthenticated, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context) .colorScheme @@ -149,7 +152,7 @@ class ServerCard extends StatelessWidget { ), const SizedBox(width: 4), Text( - 'Last synced: ${_formatDate(server.lastSyncedAt!)}', + l10n.serverCardLastSynced(_formatDate(context, server.lastSyncedAt!)), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context) .colorScheme @@ -168,20 +171,21 @@ class ServerCard extends StatelessWidget { ); } - String _formatDate(DateTime date) { + String _formatDate(BuildContext context, DateTime date) { + final l10n = AppLocalizations.of(context); final now = DateTime.now(); final difference = now.difference(date); if (difference.inDays > 7) { return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } else if (difference.inDays > 0) { - return '${difference.inDays}d ago'; + return l10n.timeDaysAgo(difference.inDays); } else if (difference.inHours > 0) { - return '${difference.inHours}h ago'; + return l10n.timeHoursAgo(difference.inHours); } else if (difference.inMinutes > 0) { - return '${difference.inMinutes}m ago'; + return l10n.timeMinutesAgo(difference.inMinutes); } else { - return 'just now'; + return l10n.timeJustNow; } } } diff --git a/lib/widgets/worldhopper_app_bar.dart b/lib/widgets/worldhopper_app_bar.dart index ba43e28..5f61006 100644 --- a/lib/widgets/worldhopper_app_bar.dart +++ b/lib/widgets/worldhopper_app_bar.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/providers/connectivity_provider.dart'; /// Custom AppBar that automatically shows offline indicator @@ -82,7 +83,7 @@ class WorldhopperAppBar extends ConsumerWidget implements PreferredSizeWidget { child: Icon( Icons.cloud_off, color: Theme.of(context).colorScheme.error, - semanticLabel: 'Offline', + semanticLabel: AppLocalizations.of(context).offlineSemanticLabel, ), ), ...?actions, diff --git a/pubspec.lock b/pubspec.lock index 94e49e7..817a9d8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -414,6 +414,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_riverpod: dependency: "direct main" description: @@ -512,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5a06e93..506d7f5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,9 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter + intl: any # State management flutter_riverpod: ^2.5.1 @@ -101,6 +104,7 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: + generate: true # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in -- 2.52.0 From 9bb7f4dee7193328378f9e7dd183ce4554c81f54 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Thu, 12 Feb 2026 22:37:30 +0100 Subject: [PATCH 2/2] chore: linting --- .claude/settings.json | 1 + AGENTS.md | 5 ++ .../settings/advanced_settings_screen.dart | 2 +- .../settings/language_settings_screen.dart | 60 +++++++++---------- 4 files changed, 35 insertions(+), 33 deletions(-) create mode 100644 AGENTS.md diff --git a/.claude/settings.json b/.claude/settings.json index 5aa54b0..1901161 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -3,6 +3,7 @@ "allow": [ "Bash(dart analyze:*)", "Bash(dart run build_runner:*)", + "Bash(dart format:*)", "Bash(find:*)", "Bash(flutter --version:*)", "Bash(flutter analyze:*)", diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..74d73ec --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# Agent Guidelines + +## After making changes + +Run `dart format` and `flutter analyze` after making changes to ensure code is properly formatted and passes static analysis with no issues. diff --git a/lib/screens/settings/advanced_settings_screen.dart b/lib/screens/settings/advanced_settings_screen.dart index 6467a65..395e88d 100644 --- a/lib/screens/settings/advanced_settings_screen.dart +++ b/lib/screens/settings/advanced_settings_screen.dart @@ -89,7 +89,7 @@ class AdvancedSettingsScreen extends ConsumerWidget { ListTile( leading: const Icon(Icons.storage_outlined), title: Text(l10n.advancedDatabaseVersion), - subtitle: Text('${AppConstants.databaseVersion}'), + subtitle: const Text('${AppConstants.databaseVersion}'), ), ListTile( leading: Icon(Platform.isIOS diff --git a/lib/screens/settings/language_settings_screen.dart b/lib/screens/settings/language_settings_screen.dart index d5d61b9..c6e197a 100644 --- a/lib/screens/settings/language_settings_screen.dart +++ b/lib/screens/settings/language_settings_screen.dart @@ -22,46 +22,42 @@ class LanguageSettingsScreen extends ConsumerWidget { const SizedBox(height: 8), Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Column( - children: [ - // System default option - ListTile( - leading: const Icon(Icons.language), - title: Text(l10n.languageSystem), - subtitle: Text(l10n.languageSystemSubtitle), - trailing: Radio( - value: null, - groupValue: currentLocale, - onChanged: (value) { + child: RadioGroup( + groupValue: currentLocale, + onChanged: (value) { + ref.read(localeNotifierProvider.notifier).setLocale(value); + }, + child: Column( + children: [ + // System default option + ListTile( + leading: const Icon(Icons.language), + title: Text(l10n.languageSystem), + subtitle: Text(l10n.languageSystemSubtitle), + trailing: const Radio( + value: null, + ), + onTap: () { ref.read(localeNotifierProvider.notifier).setLocale(null); }, ), - onTap: () { - ref.read(localeNotifierProvider.notifier).setLocale(null); - }, - ), - // Supported locales - for (final locale in AppLocalizations.supportedLocales) ...[ - const Divider(height: 1), - ListTile( - title: Text(_localeDisplayName(locale)), - trailing: Radio( - value: locale, - groupValue: currentLocale, - onChanged: (value) { + // Supported locales + for (final locale in AppLocalizations.supportedLocales) ...[ + const Divider(height: 1), + ListTile( + title: Text(_localeDisplayName(locale)), + trailing: Radio( + value: locale, + ), + onTap: () { ref .read(localeNotifierProvider.notifier) - .setLocale(value); + .setLocale(locale); }, ), - onTap: () { - ref - .read(localeNotifierProvider.notifier) - .setLocale(locale); - }, - ), + ], ], - ], + ), ), ), const SizedBox(height: 16), -- 2.52.0