feat: i18n #2

Merged
fmartingr merged 4 commits from i18n into master 2026-02-12 22:41:22 +01:00
40 changed files with 4591 additions and 440 deletions

View file

@ -3,6 +3,7 @@
"allow": [
"Bash(dart analyze:*)",
"Bash(dart run build_runner:*)",
"Bash(dart format:*)",
"Bash(find:*)",
"Bash(flutter --version:*)",
"Bash(flutter analyze:*)",

5
AGENTS.md Normal file
View file

@ -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.

6
l10n.yaml Normal file
View file

@ -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

View file

@ -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,
);

View file

@ -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,8 @@ class AppRouter {
],
errorBuilder: (context, state) => Scaffold(
body: Center(
child: Text('Page not found: ${state.uri}'),
child: Text(
AppLocalizations.of(context).routeNotFound(state.uri.toString())),
),
),
);

View file

@ -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',
};
}

435
lib/l10n/app_en.arb Normal file
View file

@ -0,0 +1,435 @@
{
"@@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",
"readersGeneralSection": "General",
"readersEinkMode": "E-ink mode",
"readersEinkModeSubtitle": "Disable animations for e-ink displays",
"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" }
}
}
}

260
lib/l10n/app_es.arb Normal file
View file

@ -0,0 +1,260 @@
{
"@@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",
"readersGeneralSection": "General",
"readersEinkMode": "Modo e-ink",
"readersEinkModeSubtitle": "Desactivar animaciones para pantallas e-ink",
"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"
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,795 @@
// 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 readersGeneralSection => 'General';
@override
String get readersEinkMode => 'E-ink mode';
@override
String get readersEinkModeSubtitle => 'Disable animations for e-ink displays';
@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';
}
}

View file

@ -0,0 +1,804 @@
// 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 readersGeneralSection => 'General';
@override
String get readersEinkMode => 'Modo e-ink';
@override
String get readersEinkModeSubtitle =>
'Desactivar animaciones para pantallas e-ink';
@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';
}
}

View file

@ -0,0 +1,48 @@
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<void> _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<void> 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);
}
}
}

View file

@ -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<LocaleNotifier, Locale?>.internal(
LocaleNotifier.new,
name: r'localeNotifierProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$localeNotifierHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LocaleNotifier = AutoDisposeNotifier<Locale?>;
// 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

View file

@ -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<FeedScreen> {
}
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<FeedScreen> {
await ref.read(opdsFeedProvider(refreshRequest).future);
},
child: _allEntries.isEmpty
? _buildEmptyState(context)
? _buildEmptyState(context, l10n)
: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
@ -200,14 +203,14 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
// 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),
);
}
@ -261,12 +264,13 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
});
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(
@ -289,12 +293,12 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
),
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
@ -312,6 +316,8 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
}
Widget _buildErrorState(BuildContext context, WidgetRef ref, Object error) {
final l10n = AppLocalizations.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
@ -321,7 +327,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
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,
),
@ -342,7 +348,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
ref.invalidate(opdsFeedProvider(refreshRequest));
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
label: Text(l10n.browserRetry),
),
],
),

View file

@ -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';
@ -60,6 +61,7 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
@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));
@ -67,9 +69,9 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
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(
@ -81,6 +83,8 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
}
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((_) {
@ -116,7 +120,7 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
await ref.read(opdsRootFeedProvider(refreshRequest).future);
},
child: _allEntries.isEmpty
? _buildEmptyState(context)
? _buildEmptyState(context, l10n)
: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
@ -194,14 +198,14 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
// 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),
);
}
@ -254,12 +258,13 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
});
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 +287,12 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
),
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
@ -305,6 +310,8 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
}
Widget _buildErrorState(BuildContext context, WidgetRef ref, Object error) {
final l10n = AppLocalizations.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
@ -314,7 +321,7 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
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,
),
@ -332,7 +339,7 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
ref.invalidate(opdsRootFeedProvider(refreshRequest));
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
label: Text(l10n.browserRetry),
),
],
),

View file

@ -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,
),
@ -73,31 +75,32 @@ 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),
@ -111,13 +114,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
@ -130,7 +133,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),
),
],
),
@ -138,7 +141,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),
@ -152,13 +155,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
@ -171,7 +174,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),
),
],
),
@ -179,7 +182,8 @@ 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),
@ -193,7 +197,7 @@ class LibraryScreen extends ConsumerWidget {
),
const SizedBox(height: 24),
Text(
'Error Loading Library',
l10n.libraryErrorLoading,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),

View file

@ -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);
@ -78,7 +80,7 @@ class PublicationDetailScreen extends ConsumerWidget {
child: Icon(
Icons.cloud_off,
color: Theme.of(context).colorScheme.error,
semanticLabel: 'Offline',
semanticLabel: l10n.offlineSemanticLabel,
),
),
]
@ -102,7 +104,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()),
@ -119,6 +120,8 @@ class PublicationDetailScreen extends ConsumerWidget {
}
Widget _buildMetadata(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Wrap(
@ -129,7 +132,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(
@ -155,6 +158,8 @@ class PublicationDetailScreen extends ConsumerWidget {
WidgetRef ref,
AsyncValue<ReadingProgress?> progressAsync,
) {
final l10n = AppLocalizations.of(context);
return progressAsync.when(
data: (progress) {
if (progress == null) {
@ -167,7 +172,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();
@ -177,8 +182,10 @@ 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(),
@ -191,6 +198,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(
@ -203,7 +212,7 @@ class PublicationDetailScreen extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Reading Progress',
l10n.publicationReadingProgress,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
@ -240,6 +249,8 @@ class PublicationDetailScreen extends ConsumerWidget {
WidgetRef ref,
AsyncValue<ReadingProgress?> progressAsync,
) {
final l10n = AppLocalizations.of(context);
// Check if entry can be read (either as stream or EPUB)
final canRead = entry.hasStreamLink || entry.isEpub;
@ -259,7 +270,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,
),
@ -294,7 +305,8 @@ 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),
@ -304,7 +316,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(
@ -312,8 +324,10 @@ 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),
@ -324,7 +338,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),
),
],
],
@ -448,22 +462,23 @@ 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);
}

View file

@ -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/helpers/snackbar_helper.dart';
import 'package:worldhopper/providers/eink_mode_provider.dart';
import 'package:worldhopper/models/enhanced_metadata.dart';
@ -41,7 +42,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
EpubController? _epubController;
bool _isLoading = true;
double _loadingProgress = 0.0;
String _loadingStatus = 'Preparing...';
String? _loadingStatus;
String? _errorMessage;
File? _epubFile;
String? _initialCfi;
@ -77,10 +78,11 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
Future<void> _loadEpub() async {
try {
final l10n = AppLocalizations.of(context);
setState(() {
_isLoading = true;
_loadingProgress = 0.0;
_loadingStatus = 'Preparing...';
_loadingStatus = l10n.epubPreparing;
_errorMessage = null;
});
@ -88,7 +90,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
if (server == null) {
setState(() {
_errorMessage = 'Server not found';
_errorMessage = l10n.readerServerNotFound;
_isLoading = false;
});
return;
@ -100,7 +102,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
if (isCached) {
setState(() {
_loadingStatus = 'Loading from cache...';
_loadingStatus = l10n.epubLoadingFromCache;
_loadingProgress = 0.5;
});
}
@ -110,18 +112,22 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
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
@ -152,9 +158,11 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
}
}
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
@ -163,8 +171,10 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
_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;
});
}
@ -261,7 +271,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
/// Build display settings based on orientation
EpubDisplaySettings _buildDisplaySettings(Orientation orientation) {
debugPrint(
'🔧 _buildDisplaySettings: orientation=$orientation, width=${MediaQuery.of(context).size.width}');
'_buildDisplaySettings: orientation=$orientation, width=${MediaQuery.of(context).size.width}');
final brightness = Theme.of(context).brightness;
final isDarkMode = brightness == Brightness.dark;
@ -302,7 +312,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
: EpubSpread.none;
debugPrint(
'🔧 _buildDisplaySettings: returning spread=$spread (width=$screenWidth)');
'_buildDisplaySettings: returning spread=$spread (width=$screenWidth)');
return EpubDisplaySettings(
flow: EpubFlow.paginated,
@ -315,6 +325,8 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
if (_isLoading) {
return Scaffold(
backgroundColor: Colors.black,
@ -343,7 +355,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
),
const SizedBox(height: 16),
Text(
_loadingStatus,
_loadingStatus ?? l10n.epubPreparing,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
@ -384,7 +396,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text('Go Back'),
child: Text(l10n.epubGoBack),
),
],
),
@ -526,16 +538,17 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
/// 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) {
@ -548,7 +561,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(l10n.commonCancel),
),
TextButton(
onPressed: () {
@ -557,7 +570,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
_performSearch(searchQuery);
}
},
child: const Text('Search'),
child: Text(l10n.epubSearch),
),
],
),
@ -572,8 +585,10 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
if (!mounted) return;
final l10n = AppLocalizations.of(context);
if (results.isEmpty) {
context.showInfoSnackBar('No results found');
context.showInfoSnackBar(l10n.epubNoResults);
return;
}
@ -585,7 +600,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
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,
),
),
@ -614,7 +629,8 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
);
} catch (e) {
if (!mounted) return;
context.showErrorSnackBar('Search failed: $e');
final l10n = AppLocalizations.of(context);
context.showErrorSnackBar(l10n.epubSearchFailed(e.toString()));
}
}
@ -664,6 +680,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
/// Build custom app bar overlay
Widget _buildCustomAppBar() {
final l10n = AppLocalizations.of(context);
final topPadding = MediaQuery.of(context).padding.top;
return Positioned(
@ -709,7 +726,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
icon: const Icon(Icons.arrow_back),
color: Colors.white,
onPressed: () => context.pop(),
tooltip: 'Back',
tooltip: l10n.epubBack,
),
Expanded(
child: Text(
@ -725,12 +742,12 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
),
// 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
@ -742,7 +759,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
_cancelAutoHideTimer();
_showChapterList(context);
},
tooltip: 'Chapters',
tooltip: l10n.epubChapters,
),
// Search button
IconButton(
@ -752,7 +769,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
_cancelAutoHideTimer();
_showSearchDialog(context);
},
tooltip: 'Search',
tooltip: l10n.epubSearch,
),
],
);

View file

@ -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';
@ -103,13 +105,15 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
@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),
),
);
}
@ -144,10 +148,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
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),
),
);
}
@ -229,7 +233,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
),
error: (error, stack) => Center(
child: Text(
'Error loading server: $error',
l10n.readerErrorLoadingServer(error.toString()),
style: const TextStyle(color: Colors.white),
),
),
@ -521,6 +525,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
),
child: Consumer(
builder: (context, ref, _) {
final l10n = AppLocalizations.of(context);
// Watch connectivity state
final connectivityState = ref.watch(connectivityStateProvider);
@ -566,12 +572,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
),
// 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
@ -614,17 +620,15 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
}
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
@ -663,7 +667,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
? 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),
@ -671,7 +675,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
? 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),
@ -680,10 +684,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
: 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(
@ -697,7 +701,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
: TwoPageMode.auto;
_setTwoPageMode(ref, newMode);
},
child: const Text('Two-page spread'),
child: Text(l10n.readersTwoPageSpread),
),
if (isPaged)
MenuItemButton(
@ -705,13 +709,14 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
trailingIcon:
firstPageIsCover ? 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 einkMode = ref.watch(einkModeNotifierProvider);
final progress = (_currentPage + 1) / streamLink.pageCount;
final globalMode = ref.watch(readingModeNotifierProvider);
@ -743,12 +748,13 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
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

View file

@ -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<AddServerScreen> {
@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<AddServerScreen> {
// 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<AddServerScreen> {
// 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<AddServerScreen> {
// 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,15 +112,15 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
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;
},
@ -126,7 +129,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
labelText: l10n.addServerPassword,
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
@ -145,7 +148,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
textInputAction: TextInputAction.done,
validator: (value) {
if (_requiresAuth && (value == null || value.isEmpty)) {
return 'Please enter a password';
return l10n.addServerValidationPasswordRequired;
}
return null;
},
@ -169,7 +172,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
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,
),
),
@ -188,7 +191,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Add Server'),
: Text(l10n.addServerButton),
),
],
),
@ -206,6 +209,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
});
try {
final l10n = AppLocalizations.of(context);
final url = _urlController.text.trim();
// Check if server already exists
@ -214,7 +218,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
if (exists) {
if (mounted) {
context.showWarningSnackBar('A server with this URL already exists');
context.showWarningSnackBar(l10n.addServerDuplicate);
}
setState(() {
_isLoading = false;
@ -236,12 +240,13 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
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) {

View file

@ -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<EditServerScreen> {
@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<EditServerScreen> {
// 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<EditServerScreen> {
// 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<EditServerScreen> {
// 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<EditServerScreen> {
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<EditServerScreen> {
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<EditServerScreen> {
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<EditServerScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Server Information',
l10n.editServerInfo,
style:
Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
@ -198,12 +200,12 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
),
const SizedBox(height: 8),
_buildInfoRow(
'Added',
l10n.editServerAdded,
_formatDateTime(server.createdAt),
),
if (server.lastSyncedAt != null)
_buildInfoRow(
'Last Synced',
l10n.editServerLastSynced,
_formatDateTime(server.lastSyncedAt!),
),
],
@ -221,7 +223,7 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save Changes'),
: Text(l10n.editServerSaveButton),
),
],
),
@ -234,7 +236,7 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
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())),
],
),
),
@ -281,6 +283,8 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
});
try {
final l10n = AppLocalizations.of(context);
// Create updated server
final updatedServer = _originalServer!.copyWith(
name: _nameController.text.trim(),
@ -295,13 +299,13 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
.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) {

View file

@ -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(
@ -49,7 +51,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;
@ -65,13 +67,14 @@ class ServerListScreen extends ConsumerWidget {
},
onDelete: () async {
final confirmed =
await _confirmDelete(context, server.name);
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));
}
}
},
@ -87,11 +90,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),
),
],
),
@ -102,12 +105,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,
@ -120,14 +123,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)
@ -142,23 +145,24 @@ class ServerListScreen extends ConsumerWidget {
);
}
Future<bool> _confirmDelete(BuildContext context, String serverName) async {
Future<bool> _confirmDelete(
BuildContext context, AppLocalizations l10n, String serverName) async {
final result = await showDialog<bool>(
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),
),
],
),

View file

@ -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),

View file

@ -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,36 +73,35 @@ 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'),
subtitle: Text('${AppConstants.databaseVersion}'),
ListTile(
leading: const Icon(Icons.storage_outlined),
title: Text(l10n.advancedDatabaseVersion),
subtitle: const Text('${AppConstants.databaseVersion}'),
),
ListTile(
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('Version: ${info.version}')
@ -111,8 +112,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);
},
),
],
@ -124,7 +124,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'),
),
),

View file

@ -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,15 +10,16 @@ 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: RadioGroup<ThemeMode>(
@ -31,7 +33,7 @@ class AppearanceSettingsScreen extends ConsumerWidget {
children: [
ListTile(
leading: const Icon(Icons.light_mode),
title: const Text('Light'),
title: Text(l10n.themeLight),
trailing: const Radio<ThemeMode>(
value: ThemeMode.light,
),
@ -44,7 +46,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: const Radio<ThemeMode>(
value: ThemeMode.dark,
),
@ -57,8 +59,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: const Radio<ThemeMode>(
value: ThemeMode.system,
),

View file

@ -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,44 +9,46 @@ 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'),
title: Text(l10n.devTestsSuccess),
onTap: () =>
context.showSuccessSnackBar('This is a success message'),
context.showSuccessSnackBar(l10n.devTestsSuccessMessage),
),
const Divider(height: 1),
ListTile(
leading: Icon(Icons.error,
color: Theme.of(context).colorScheme.error),
title: const Text('Error'),
title: Text(l10n.devTestsError),
onTap: () =>
context.showErrorSnackBar('This is an error message'),
context.showErrorSnackBar(l10n.devTestsErrorMessage),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.warning, color: Colors.orange),
title: const Text('Warning'),
title: Text(l10n.devTestsWarning),
onTap: () =>
context.showWarningSnackBar('This is a warning message'),
context.showWarningSnackBar(l10n.devTestsWarningMessage),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('Info'),
title: Text(l10n.devTestsInfo),
onTap: () =>
context.showInfoSnackBar('This is an info message'),
context.showInfoSnackBar(l10n.devTestsInfoMessage),
),
],
),

View file

@ -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, _FilterQualityOption>{
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: [
@ -31,8 +56,8 @@ class FilterQualitySettingsScreen extends ConsumerWidget {
},
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),
@ -55,9 +80,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,
),
@ -80,26 +103,3 @@ class _FilterQualityOption {
required this.icon,
});
}
const _filterQualityOptions = <FilterQuality, _FilterQualityOption>{
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,
),
};

View file

@ -0,0 +1,85 @@
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: RadioGroup<Locale?>(
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<Locale?>(
value: 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<Locale?>(
value: locale,
),
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 = <String, String>{
'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;
}
}

View file

@ -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 = <int, _PrecachePagesOption>{
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: [
@ -31,8 +66,8 @@ class PrecachePagesSettingsScreen extends ConsumerWidget {
},
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),
@ -55,9 +90,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,
),
@ -80,36 +113,3 @@ class _PrecachePagesOption {
required this.icon,
});
}
const _precachePagesOptions = <int, _PrecachePagesOption>{
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,
),
};

View file

@ -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/eink_mode_provider.dart';
import 'package:worldhopper/providers/filter_quality_provider.dart';
import 'package:worldhopper/providers/precache_pages_provider.dart';
@ -15,30 +17,9 @@ import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
class ReadersSettingsScreen extends ConsumerWidget {
const ReadersSettingsScreen({super.key});
static const _readingModeLabels = <ReadingMode, String>{
ReadingMode.ltr: 'Left to right',
ReadingMode.rtl: 'Right to left',
ReadingMode.verticalContinuous: 'Vertical scroll',
};
static const _filterQualityLabels = <FilterQuality, String>{
FilterQuality.none: 'None',
FilterQuality.low: 'Low',
FilterQuality.medium: 'Medium',
FilterQuality.high: 'High',
};
static const _precachePagesLabels = <int, String>{
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 einkMode = ref.watch(einkModeNotifierProvider);
final readingMode = ref.watch(readingModeNotifierProvider);
final filterQuality = ref.watch(filterQualityNotifierProvider);
@ -47,20 +28,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, 'General'),
_buildSectionHeader(context, l10n.readersGeneralSection),
Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
SwitchListTile(
secondary: const Icon(Icons.tablet_android),
title: const Text('E-ink mode'),
subtitle: const Text('Disable animations for e-ink displays'),
title: Text(l10n.readersEinkMode),
subtitle: Text(l10n.readersEinkModeSubtitle),
value: einkMode,
onChanged: (enabled) {
ref
@ -71,16 +52,15 @@ class ReadersSettingsScreen extends ConsumerWidget {
],
),
),
_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(
@ -93,9 +73,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(
@ -108,9 +87,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(
@ -123,9 +101,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
@ -137,9 +114,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

View file

@ -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, _ReadingModeOption>{
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: [
@ -31,8 +51,8 @@ class ReadingModeSettingsScreen extends ConsumerWidget {
},
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),
@ -55,9 +75,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,
),
@ -80,21 +98,3 @@ class _ReadingModeOption {
required this.icon,
});
}
const _readingModeOptions = <ReadingMode, _ReadingModeOption>{
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,
),
};

View file

@ -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(

View file

@ -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<MainShellScreen> {
@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,
),
],
),

View file

@ -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';
@ -83,7 +84,7 @@ class _NextInSeriesOverlayState extends ConsumerState<NextInSeriesOverlay> {
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,
);
@ -94,6 +95,7 @@ class _NextInSeriesOverlayState extends ConsumerState<NextInSeriesOverlay> {
// ---------------------------------------------------------------------------
Widget _buildFinishedOverlay(BuildContext context) {
final l10n = AppLocalizations.of(context);
final connectivityState = ref.watch(connectivityStateProvider);
final isOnline = connectivityState.whenOrNull(
data: (online) => online,
@ -106,8 +108,10 @@ class _NextInSeriesOverlayState extends ConsumerState<NextInSeriesOverlay> {
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,
);
}

View file

@ -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
@ -90,7 +93,7 @@ class PublicationCard extends StatelessWidget {
),
const SizedBox(width: 4),
Text(
'Collection',
l10n.publicationCollection,
style: Theme.of(context)
.textTheme
.bodySmall

View file

@ -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,
),
),
@ -121,7 +124,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
@ -145,7 +148,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,
),
),
@ -207,6 +210,8 @@ class RecentlyReadCard extends ConsumerWidget {
WidgetRef ref,
dynamic publication,
) async {
final l10n = AppLocalizations.of(context);
// Convert to OPDSEntry
final entry = publication.toOPDSEntry();
@ -227,7 +232,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
@ -235,7 +240,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
@ -268,7 +273,7 @@ class RecentlyReadCard extends ConsumerWidget {
} else {
// No valid link found
context.showInfoSnackBar(
'Unsupported format',
l10n.recentlyReadUnsupportedFormat,
duration: const Duration(seconds: 2),
);
}
@ -280,6 +285,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(
@ -293,13 +299,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),
],
),
),
@ -315,7 +321,7 @@ class RecentlyReadCard extends ConsumerWidget {
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),
),
],
@ -337,25 +343,23 @@ class RecentlyReadCard extends ConsumerWidget {
}
Future<bool> _confirmRemoval(BuildContext context, String title) async {
final l10n = AppLocalizations.of(context);
final result = await showDialog<bool>(
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),
),
],
),
@ -379,20 +383,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());
@ -402,7 +410,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(),
@ -423,22 +431,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());
}
}
}

View file

@ -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,24 @@ 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 +131,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 +153,8 @@ 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 +173,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;
}
}
}

View file

@ -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,

View file

@ -430,6 +430,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:
@ -528,6 +533,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:

View file

@ -30,6 +30,9 @@ environment:
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: any
# State management
flutter_riverpod: ^2.5.1
@ -102,6 +105,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