feat: i18n #2
40 changed files with 4591 additions and 440 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(dart analyze:*)",
|
"Bash(dart analyze:*)",
|
||||||
"Bash(dart run build_runner:*)",
|
"Bash(dart run build_runner:*)",
|
||||||
|
"Bash(dart format:*)",
|
||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(flutter --version:*)",
|
"Bash(flutter --version:*)",
|
||||||
"Bash(flutter analyze:*)",
|
"Bash(flutter analyze:*)",
|
||||||
|
|
|
||||||
5
AGENTS.md
Normal file
5
AGENTS.md
Normal 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
6
l10n.yaml
Normal 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
|
||||||
12
lib/app.dart
12
lib/app.dart
|
|
@ -1,9 +1,12 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/router.dart';
|
||||||
import 'package:worldhopper/config/theme.dart';
|
import 'package:worldhopper/config/theme.dart';
|
||||||
import 'package:worldhopper/config/constants.dart';
|
import 'package:worldhopper/config/constants.dart';
|
||||||
import 'package:worldhopper/providers/theme_provider.dart';
|
import 'package:worldhopper/providers/theme_provider.dart';
|
||||||
|
import 'package:worldhopper/providers/locale_provider.dart';
|
||||||
|
|
||||||
/// Root application widget
|
/// Root application widget
|
||||||
class App extends ConsumerWidget {
|
class App extends ConsumerWidget {
|
||||||
|
|
@ -12,12 +15,21 @@ class App extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final themeMode = ref.watch(themeNotifierProvider);
|
final themeMode = ref.watch(themeNotifierProvider);
|
||||||
|
final locale = ref.watch(localeNotifierProvider);
|
||||||
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: AppConstants.appName,
|
title: AppConstants.appName,
|
||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
darkTheme: AppTheme.darkTheme,
|
darkTheme: AppTheme.darkTheme,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
|
locale: locale,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
AppLocalizations.delegate,
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
routerConfig: AppRouter.router,
|
routerConfig: AppRouter.router,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:worldhopper/config/constants.dart';
|
import 'package:worldhopper/config/constants.dart';
|
||||||
import 'package:worldhopper/screens/servers/server_list_screen.dart';
|
import 'package:worldhopper/screens/servers/server_list_screen.dart';
|
||||||
|
|
@ -140,7 +141,8 @@ class AppRouter {
|
||||||
],
|
],
|
||||||
errorBuilder: (context, state) => Scaffold(
|
errorBuilder: (context, state) => Scaffold(
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Text('Page not found: ${state.uri}'),
|
child: Text(
|
||||||
|
AppLocalizations.of(context).routeNotFound(state.uri.toString())),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
44
lib/helpers/l10n_helpers.dart
Normal file
44
lib/helpers/l10n_helpers.dart
Normal 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
435
lib/l10n/app_en.arb
Normal 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
260
lib/l10n/app_es.arb
Normal 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"
|
||||||
|
}
|
||||||
1513
lib/l10n/app_localizations.dart
Normal file
1513
lib/l10n/app_localizations.dart
Normal file
File diff suppressed because it is too large
Load diff
795
lib/l10n/app_localizations_en.dart
Normal file
795
lib/l10n/app_localizations_en.dart
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
804
lib/l10n/app_localizations_es.dart
Normal file
804
lib/l10n/app_localizations_es.dart
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
48
lib/providers/locale_provider.dart
Normal file
48
lib/providers/locale_provider.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
lib/providers/locale_provider.g.dart
Normal file
29
lib/providers/locale_provider.g.dart
Normal 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
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/config/constants.dart';
|
import 'package:worldhopper/config/constants.dart';
|
||||||
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
||||||
import 'package:worldhopper/models/opds_feed.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) {
|
Widget _buildFeedContent(BuildContext context, WidgetRef ref, OPDSFeed feed) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
// Initialize entries only once when first loaded
|
// Initialize entries only once when first loaded
|
||||||
if (!_initialized) {
|
if (!_initialized) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
|
@ -122,7 +125,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||||
await ref.read(opdsFeedProvider(refreshRequest).future);
|
await ref.read(opdsFeedProvider(refreshRequest).future);
|
||||||
},
|
},
|
||||||
child: _allEntries.isEmpty
|
child: _allEntries.isEmpty
|
||||||
? _buildEmptyState(context)
|
? _buildEmptyState(context, l10n)
|
||||||
: LayoutBuilder(
|
: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
|
@ -200,14 +203,14 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||||
// Warn for uncached EPUBs
|
// Warn for uncached EPUBs
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
context.showInfoSnackBar(
|
context.showInfoSnackBar(
|
||||||
'This EPUB is not cached. You may not be able to read it offline.',
|
l10n.browserOfflineEpubNotCached,
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
);
|
);
|
||||||
} else if (entry.hasStreamLink) {
|
} else if (entry.hasStreamLink) {
|
||||||
// Warn for OPDS-PS streams
|
// Warn for OPDS-PS streams
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
context.showInfoSnackBar(
|
context.showInfoSnackBar(
|
||||||
'You are offline. Pages may not load properly.',
|
l10n.browserOfflinePagesWarning,
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -261,12 +264,13 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mounted) {
|
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(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
|
@ -289,12 +293,12 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'No Content Available',
|
l10n.browserNoContent,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'This collection appears to be empty',
|
l10n.browserCollectionEmpty,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
|
|
@ -312,6 +316,8 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildErrorState(BuildContext context, WidgetRef ref, Object error) {
|
Widget _buildErrorState(BuildContext context, WidgetRef ref, Object error) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
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 Icon(Icons.error_outline, size: 64, color: Colors.red),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'Failed to Load Feed',
|
l10n.browserFailedToLoadFeed,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
|
@ -342,7 +348,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
|
||||||
ref.invalidate(opdsFeedProvider(refreshRequest));
|
ref.invalidate(opdsFeedProvider(refreshRequest));
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Retry'),
|
label: Text(l10n.browserRetry),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/config/constants.dart';
|
import 'package:worldhopper/config/constants.dart';
|
||||||
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
||||||
import 'package:worldhopper/models/opds_feed.dart';
|
import 'package:worldhopper/models/opds_feed.dart';
|
||||||
|
|
@ -60,6 +61,7 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final serverAsync = ref.watch(serverProvider(widget.serverId));
|
final serverAsync = ref.watch(serverProvider(widget.serverId));
|
||||||
final feedRequest = RootFeedRequest(serverId: widget.serverId);
|
final feedRequest = RootFeedRequest(serverId: widget.serverId);
|
||||||
final feedAsync = ref.watch(opdsRootFeedProvider(feedRequest));
|
final feedAsync = ref.watch(opdsRootFeedProvider(feedRequest));
|
||||||
|
|
@ -67,9 +69,9 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: WorldhopperAppBar(
|
appBar: WorldhopperAppBar(
|
||||||
title: serverAsync.when(
|
title: serverAsync.when(
|
||||||
data: (server) => Text(server?.name ?? 'Library'),
|
data: (server) => Text(server?.name ?? l10n.browserLibrary),
|
||||||
loading: () => const Text('Loading...'),
|
loading: () => Text(l10n.browserLoading),
|
||||||
error: (_, __) => const Text('Library'),
|
error: (_, __) => Text(l10n.browserLibrary),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: feedAsync.when(
|
body: feedAsync.when(
|
||||||
|
|
@ -81,6 +83,8 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFeedContent(BuildContext context, WidgetRef ref, OPDSFeed feed) {
|
Widget _buildFeedContent(BuildContext context, WidgetRef ref, OPDSFeed feed) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
// Initialize entries only once when first loaded
|
// Initialize entries only once when first loaded
|
||||||
if (!_initialized) {
|
if (!_initialized) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
|
@ -116,7 +120,7 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
|
||||||
await ref.read(opdsRootFeedProvider(refreshRequest).future);
|
await ref.read(opdsRootFeedProvider(refreshRequest).future);
|
||||||
},
|
},
|
||||||
child: _allEntries.isEmpty
|
child: _allEntries.isEmpty
|
||||||
? _buildEmptyState(context)
|
? _buildEmptyState(context, l10n)
|
||||||
: LayoutBuilder(
|
: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
|
@ -194,14 +198,14 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
|
||||||
// Warn for uncached EPUBs
|
// Warn for uncached EPUBs
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
context.showInfoSnackBar(
|
context.showInfoSnackBar(
|
||||||
'This EPUB is not cached. You may not be able to read it offline.',
|
l10n.browserOfflineEpubNotCached,
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
);
|
);
|
||||||
} else if (entry.hasStreamLink) {
|
} else if (entry.hasStreamLink) {
|
||||||
// Warn for OPDS-PS streams
|
// Warn for OPDS-PS streams
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
context.showInfoSnackBar(
|
context.showInfoSnackBar(
|
||||||
'You are offline. Pages may not load properly.',
|
l10n.browserOfflinePagesWarning,
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -254,12 +258,13 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mounted) {
|
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(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
|
@ -282,12 +287,12 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'No Content Available',
|
l10n.browserNoContent,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'This library appears to be empty',
|
l10n.browserLibraryEmpty,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
|
|
@ -305,6 +310,8 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildErrorState(BuildContext context, WidgetRef ref, Object error) {
|
Widget _buildErrorState(BuildContext context, WidgetRef ref, Object error) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
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 Icon(Icons.error_outline, size: 64, color: Colors.red),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'Failed to Load Library',
|
l10n.browserFailedToLoadLibrary,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
|
@ -332,7 +339,7 @@ class _LibraryBrowserScreenState extends ConsumerState<LibraryBrowserScreen> {
|
||||||
ref.invalidate(opdsRootFeedProvider(refreshRequest));
|
ref.invalidate(opdsRootFeedProvider(refreshRequest));
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Retry'),
|
label: Text(l10n.browserRetry),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/config/constants.dart';
|
import 'package:worldhopper/config/constants.dart';
|
||||||
import 'package:worldhopper/providers/reading_progress_provider.dart';
|
import 'package:worldhopper/providers/reading_progress_provider.dart';
|
||||||
import 'package:worldhopper/providers/server_provider.dart';
|
import 'package:worldhopper/providers/server_provider.dart';
|
||||||
|
|
@ -13,11 +14,12 @@ class LibraryScreen extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final recentlyReadAsync = ref.watch(recentlyReadProvider);
|
final recentlyReadAsync = ref.watch(recentlyReadProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: const WorldhopperAppBar(
|
appBar: WorldhopperAppBar(
|
||||||
title: Text('Library'),
|
title: Text(l10n.libraryTitle),
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
|
|
@ -26,18 +28,18 @@ class LibraryScreen extends ConsumerWidget {
|
||||||
child: recentlyReadAsync.when(
|
child: recentlyReadAsync.when(
|
||||||
data: (recentlyRead) {
|
data: (recentlyRead) {
|
||||||
if (recentlyRead.isEmpty) {
|
if (recentlyRead.isEmpty) {
|
||||||
return _buildEmptyState(context, ref);
|
return _buildEmptyState(context, ref, l10n);
|
||||||
}
|
}
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Recently read section header
|
// Recently read section header
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
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(
|
child: Text(
|
||||||
'Recently Read',
|
l10n.libraryRecentlyRead,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
|
@ -73,31 +75,32 @@ class LibraryScreen extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
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);
|
final serversAsync = ref.watch(serverListProvider);
|
||||||
|
|
||||||
return serversAsync.when(
|
return serversAsync.when(
|
||||||
data: (servers) {
|
data: (servers) {
|
||||||
// Case 1: No servers configured
|
// Case 1: No servers configured
|
||||||
if (servers.isEmpty) {
|
if (servers.isEmpty) {
|
||||||
return _buildNoServersState(context);
|
return _buildNoServersState(context, l10n);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 2: Servers exist but no reading history
|
// Case 2: Servers exist but no reading history
|
||||||
return _buildNoHistoryState(context);
|
return _buildNoHistoryState(context, l10n);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
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(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
|
|
@ -111,13 +114,13 @@ class LibraryScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'No Servers Configured',
|
l10n.libraryNoServers,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'Add an OPDS server to start reading books',
|
l10n.libraryNoServersSubtitle,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
|
|
@ -130,7 +133,7 @@ class LibraryScreen extends ConsumerWidget {
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => context.go(AppConstants.routeServerList),
|
onPressed: () => context.go(AppConstants.routeServerList),
|
||||||
icon: const Icon(Icons.add),
|
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(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
|
|
@ -152,13 +155,13 @@ class LibraryScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'No Reading History',
|
l10n.libraryNoHistory,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'Start reading a book to see it here',
|
l10n.libraryNoHistorySubtitle,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
|
|
@ -171,7 +174,7 @@ class LibraryScreen extends ConsumerWidget {
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => context.go(AppConstants.routeServerList),
|
onPressed: () => context.go(AppConstants.routeServerList),
|
||||||
icon: const Icon(Icons.dns),
|
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(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
|
|
@ -193,7 +197,7 @@ class LibraryScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'Error Loading Library',
|
l10n.libraryErrorLoading,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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/config/constants.dart';
|
||||||
import 'package:worldhopper/models/opds_entry.dart';
|
import 'package:worldhopper/models/opds_entry.dart';
|
||||||
import 'package:worldhopper/providers/reading_progress_provider.dart';
|
import 'package:worldhopper/providers/reading_progress_provider.dart';
|
||||||
|
|
@ -35,7 +36,6 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
// extendBodyBehindAppBar: true,
|
|
||||||
appBar: _buildAppBar(context, ref),
|
appBar: _buildAppBar(context, ref),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
|
|
@ -58,6 +58,8 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
PreferredSizeWidget _buildAppBar(BuildContext context, WidgetRef ref) {
|
PreferredSizeWidget _buildAppBar(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
// Watch connectivity state
|
// Watch connectivity state
|
||||||
final connectivityState = ref.watch(connectivityStateProvider);
|
final connectivityState = ref.watch(connectivityStateProvider);
|
||||||
|
|
||||||
|
|
@ -78,7 +80,7 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.cloud_off,
|
Icons.cloud_off,
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
semanticLabel: 'Offline',
|
semanticLabel: l10n.offlineSemanticLabel,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
@ -102,7 +104,6 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
|
|
||||||
return CachedNetworkImage(
|
return CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
// fit: BoxFit.cover,
|
|
||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
child: const Center(child: CircularProgressIndicator()),
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
|
|
@ -119,6 +120,8 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMetadata(BuildContext context) {
|
Widget _buildMetadata(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
|
|
@ -129,7 +132,7 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
if (entry.hasStreamLink)
|
if (entry.hasStreamLink)
|
||||||
Chip(
|
Chip(
|
||||||
avatar: const Icon(Icons.auto_stories, size: 18),
|
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)
|
if (entry.categories.isNotEmpty)
|
||||||
...entry.categories.map(
|
...entry.categories.map(
|
||||||
|
|
@ -155,6 +158,8 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
AsyncValue<ReadingProgress?> progressAsync,
|
AsyncValue<ReadingProgress?> progressAsync,
|
||||||
) {
|
) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
return progressAsync.when(
|
return progressAsync.when(
|
||||||
data: (progress) {
|
data: (progress) {
|
||||||
if (progress == null) {
|
if (progress == null) {
|
||||||
|
|
@ -167,7 +172,7 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
return _buildProgressCardContent(
|
return _buildProgressCardContent(
|
||||||
context,
|
context,
|
||||||
percentage: percentage,
|
percentage: percentage,
|
||||||
label: 'Page ${lastRead + 1} of $pageCount',
|
label: l10n.publicationPageOfTotal(lastRead + 1, pageCount),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
|
|
@ -177,8 +182,10 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
context,
|
context,
|
||||||
percentage: progress.progressPercentage,
|
percentage: progress.progressPercentage,
|
||||||
label: entry.isEpub
|
label: entry.isEpub
|
||||||
? 'Last read: ${_formatDateTime(progress.lastReadAt)}'
|
? l10n.publicationLastRead(
|
||||||
: 'Page ${progress.currentPage + 1} of ${progress.totalPages}',
|
_formatDateTime(context, progress.lastReadAt))
|
||||||
|
: l10n.publicationPageOfTotal(
|
||||||
|
progress.currentPage + 1, progress.totalPages),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const SizedBox.shrink(),
|
loading: () => const SizedBox.shrink(),
|
||||||
|
|
@ -191,6 +198,8 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
required double percentage,
|
required double percentage,
|
||||||
required String label,
|
required String label,
|
||||||
}) {
|
}) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
child: Card(
|
child: Card(
|
||||||
|
|
@ -203,7 +212,7 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Reading Progress',
|
l10n.publicationReadingProgress,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
|
@ -240,6 +249,8 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
AsyncValue<ReadingProgress?> progressAsync,
|
AsyncValue<ReadingProgress?> progressAsync,
|
||||||
) {
|
) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
// Check if entry can be read (either as stream or EPUB)
|
// Check if entry can be read (either as stream or EPUB)
|
||||||
final canRead = entry.hasStreamLink || entry.isEpub;
|
final canRead = entry.hasStreamLink || entry.isEpub;
|
||||||
|
|
||||||
|
|
@ -259,7 +270,7 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'This publication cannot be read in the app',
|
l10n.publicationCannotRead,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
),
|
),
|
||||||
|
|
@ -294,7 +305,8 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.play_arrow),
|
icon: const Icon(Icons.play_arrow),
|
||||||
label: Text(
|
label: Text(
|
||||||
'Continue reading from page ${entry.streamLink!.lastRead! + 1}',
|
l10n.publicationContinueFromPage(
|
||||||
|
entry.streamLink!.lastRead! + 1),
|
||||||
),
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
|
@ -304,7 +316,7 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () => _navigateToReader(context, ref, 0),
|
onPressed: () => _navigateToReader(context, ref, 0),
|
||||||
icon: const Icon(Icons.restart_alt),
|
icon: const Icon(Icons.restart_alt),
|
||||||
label: const Text('Start reading'),
|
label: Text(l10n.publicationStartReading),
|
||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
|
|
@ -312,8 +324,10 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
icon: Icon(isStarted ? Icons.play_arrow : Icons.play_circle),
|
icon: Icon(isStarted ? Icons.play_arrow : Icons.play_circle),
|
||||||
label: Text(
|
label: Text(
|
||||||
isStarted
|
isStarted
|
||||||
? (isCompleted ? 'Read Again' : 'Continue Reading')
|
? (isCompleted
|
||||||
: 'Start Reading',
|
? l10n.publicationReadAgain
|
||||||
|
: l10n.publicationContinueReading)
|
||||||
|
: l10n.publicationStartReading,
|
||||||
),
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
|
@ -324,7 +338,7 @@ class PublicationDetailScreen extends ConsumerWidget {
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () => _startFromBeginning(context, ref),
|
onPressed: () => _startFromBeginning(context, ref),
|
||||||
icon: const Icon(Icons.restart_alt),
|
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')}';
|
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 now = DateTime.now();
|
||||||
final difference = now.difference(dateTime);
|
final difference = now.difference(dateTime);
|
||||||
|
|
||||||
if (difference.inDays == 0) {
|
if (difference.inDays == 0) {
|
||||||
if (difference.inHours == 0) {
|
if (difference.inHours == 0) {
|
||||||
if (difference.inMinutes == 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) {
|
} else if (difference.inDays == 1) {
|
||||||
return 'Yesterday';
|
return l10n.timeYesterday;
|
||||||
} else if (difference.inDays < 7) {
|
} else if (difference.inDays < 7) {
|
||||||
return '${difference.inDays} days ago';
|
return l10n.timeDaysAgoLong(difference.inDays);
|
||||||
} else {
|
} else {
|
||||||
return _formatDate(dateTime);
|
return _formatDate(dateTime);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
import 'package:flutter_epub_viewer/flutter_epub_viewer.dart';
|
import 'package:flutter_epub_viewer/flutter_epub_viewer.dart';
|
||||||
import 'package:go_router/go_router.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/helpers/snackbar_helper.dart';
|
||||||
import 'package:worldhopper/providers/eink_mode_provider.dart';
|
import 'package:worldhopper/providers/eink_mode_provider.dart';
|
||||||
import 'package:worldhopper/models/enhanced_metadata.dart';
|
import 'package:worldhopper/models/enhanced_metadata.dart';
|
||||||
|
|
@ -41,7 +42,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
EpubController? _epubController;
|
EpubController? _epubController;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
double _loadingProgress = 0.0;
|
double _loadingProgress = 0.0;
|
||||||
String _loadingStatus = 'Preparing...';
|
String? _loadingStatus;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
File? _epubFile;
|
File? _epubFile;
|
||||||
String? _initialCfi;
|
String? _initialCfi;
|
||||||
|
|
@ -77,10 +78,11 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
|
|
||||||
Future<void> _loadEpub() async {
|
Future<void> _loadEpub() async {
|
||||||
try {
|
try {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_loadingProgress = 0.0;
|
_loadingProgress = 0.0;
|
||||||
_loadingStatus = 'Preparing...';
|
_loadingStatus = l10n.epubPreparing;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -88,7 +90,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
|
|
||||||
if (server == null) {
|
if (server == null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = 'Server not found';
|
_errorMessage = l10n.readerServerNotFound;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -100,7 +102,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
|
|
||||||
if (isCached) {
|
if (isCached) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_loadingStatus = 'Loading from cache...';
|
_loadingStatus = l10n.epubLoadingFromCache;
|
||||||
_loadingProgress = 0.5;
|
_loadingProgress = 0.5;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -110,18 +112,22 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
widget.entry,
|
widget.entry,
|
||||||
server,
|
server,
|
||||||
onProgress: (progress) {
|
onProgress: (progress) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
setState(() {
|
setState(() {
|
||||||
_loadingProgress = progress * 0.8; // Reserve 0.8-1.0 for processing
|
_loadingProgress = progress * 0.8; // Reserve 0.8-1.0 for processing
|
||||||
_loadingStatus = isCached
|
_loadingStatus = isCached
|
||||||
? 'Loading from cache...'
|
? l10n.epubLoadingFromCache
|
||||||
: 'Downloading... ${(progress * 100).toInt()}%';
|
: l10n.epubDownloading((progress * 100).toInt());
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final l10nAfter = AppLocalizations.of(context);
|
||||||
setState(() {
|
setState(() {
|
||||||
_loadingProgress = 0.85;
|
_loadingProgress = 0.85;
|
||||||
_loadingStatus = 'Processing EPUB...';
|
_loadingStatus = l10nAfter.epubProcessing;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cache the publication
|
// Cache the publication
|
||||||
|
|
@ -152,9 +158,11 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final l10nFinal = AppLocalizations.of(context);
|
||||||
setState(() {
|
setState(() {
|
||||||
_loadingProgress = 1.0;
|
_loadingProgress = 1.0;
|
||||||
_loadingStatus = 'Opening reader...';
|
_loadingStatus = l10nFinal.epubOpeningReader;
|
||||||
_epubFile = file;
|
_epubFile = file;
|
||||||
_initialCfi = cfi;
|
_initialCfi = cfi;
|
||||||
// Generate a unique key to force widget recreation and reset viewport
|
// Generate a unique key to force widget recreation and reset viewport
|
||||||
|
|
@ -163,8 +171,10 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = 'Error loading EPUB: $e';
|
_errorMessage = l10n.epubErrorLoading(e.toString());
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -261,7 +271,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
/// Build display settings based on orientation
|
/// Build display settings based on orientation
|
||||||
EpubDisplaySettings _buildDisplaySettings(Orientation orientation) {
|
EpubDisplaySettings _buildDisplaySettings(Orientation orientation) {
|
||||||
debugPrint(
|
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 brightness = Theme.of(context).brightness;
|
||||||
final isDarkMode = brightness == Brightness.dark;
|
final isDarkMode = brightness == Brightness.dark;
|
||||||
|
|
@ -302,7 +312,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
: EpubSpread.none;
|
: EpubSpread.none;
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔧 _buildDisplaySettings: returning spread=$spread (width=$screenWidth)');
|
'_buildDisplaySettings: returning spread=$spread (width=$screenWidth)');
|
||||||
|
|
||||||
return EpubDisplaySettings(
|
return EpubDisplaySettings(
|
||||||
flow: EpubFlow.paginated,
|
flow: EpubFlow.paginated,
|
||||||
|
|
@ -315,6 +325,8 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
|
|
@ -343,7 +355,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
_loadingStatus,
|
_loadingStatus ?? l10n.epubPreparing,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
|
|
@ -384,7 +396,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
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
|
/// Show search dialog
|
||||||
void _showSearchDialog(BuildContext context) {
|
void _showSearchDialog(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
String searchQuery = '';
|
String searchQuery = '';
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Search'),
|
title: Text(l10n.epubSearch),
|
||||||
content: TextField(
|
content: TextField(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Enter search term...',
|
hintText: l10n.epubSearchHint,
|
||||||
),
|
),
|
||||||
onChanged: (value) => searchQuery = value,
|
onChanged: (value) => searchQuery = value,
|
||||||
onSubmitted: (value) {
|
onSubmitted: (value) {
|
||||||
|
|
@ -548,7 +561,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: Text(l10n.commonCancel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
|
@ -557,7 +570,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
_performSearch(searchQuery);
|
_performSearch(searchQuery);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Search'),
|
child: Text(l10n.epubSearch),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -572,8 +585,10 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
if (results.isEmpty) {
|
if (results.isEmpty) {
|
||||||
context.showInfoSnackBar('No results found');
|
context.showInfoSnackBar(l10n.epubNoResults);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -585,7 +600,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
'${results.length} results for "$query"',
|
l10n.epubSearchResults(results.length, query),
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -614,7 +629,8 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
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
|
/// Build custom app bar overlay
|
||||||
Widget _buildCustomAppBar() {
|
Widget _buildCustomAppBar() {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
|
|
@ -709,7 +726,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
tooltip: 'Back',
|
tooltip: l10n.epubBack,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -725,12 +742,12 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
),
|
),
|
||||||
// Offline indicator
|
// Offline indicator
|
||||||
if (isOffline)
|
if (isOffline)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.cloud_off,
|
Icons.cloud_off,
|
||||||
color: Colors.redAccent,
|
color: Colors.redAccent,
|
||||||
semanticLabel: 'Offline',
|
semanticLabel: l10n.offlineSemanticLabel,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Chapter navigation button
|
// Chapter navigation button
|
||||||
|
|
@ -742,7 +759,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
_cancelAutoHideTimer();
|
_cancelAutoHideTimer();
|
||||||
_showChapterList(context);
|
_showChapterList(context);
|
||||||
},
|
},
|
||||||
tooltip: 'Chapters',
|
tooltip: l10n.epubChapters,
|
||||||
),
|
),
|
||||||
// Search button
|
// Search button
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
@ -752,7 +769,7 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
|
||||||
_cancelAutoHideTimer();
|
_cancelAutoHideTimer();
|
||||||
_showSearchDialog(context);
|
_showSearchDialog(context);
|
||||||
},
|
},
|
||||||
tooltip: 'Search',
|
tooltip: l10n.epubSearch,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
import 'package:photo_view/photo_view.dart';
|
||||||
import 'package:photo_view/photo_view_gallery.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/enhanced_metadata.dart';
|
||||||
import 'package:worldhopper/models/opds_entry.dart';
|
import 'package:worldhopper/models/opds_entry.dart';
|
||||||
import 'package:worldhopper/models/opds_stream_link.dart';
|
import 'package:worldhopper/models/opds_stream_link.dart';
|
||||||
|
|
@ -103,13 +105,15 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
if (!widget.entry.hasStreamLink) {
|
if (!widget.entry.hasStreamLink) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Error'),
|
title: Text(l10n.readerError),
|
||||||
),
|
),
|
||||||
body: const Center(
|
body: Center(
|
||||||
child: Text('This publication cannot be read (no stream link)'),
|
child: Text(l10n.readerNoStreamLink),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -144,10 +148,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||||
serverAsync.when(
|
serverAsync.when(
|
||||||
data: (server) {
|
data: (server) {
|
||||||
if (server == null) {
|
if (server == null) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Server not found',
|
l10n.readerServerNotFound,
|
||||||
style: TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -229,7 +233,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||||
),
|
),
|
||||||
error: (error, stack) => Center(
|
error: (error, stack) => Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Error loading server: $error',
|
l10n.readerErrorLoadingServer(error.toString()),
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -521,6 +525,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||||
),
|
),
|
||||||
child: Consumer(
|
child: Consumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
// Watch connectivity state
|
// Watch connectivity state
|
||||||
final connectivityState = ref.watch(connectivityStateProvider);
|
final connectivityState = ref.watch(connectivityStateProvider);
|
||||||
|
|
||||||
|
|
@ -566,12 +572,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||||
),
|
),
|
||||||
// Offline indicator
|
// Offline indicator
|
||||||
if (isOffline)
|
if (isOffline)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.cloud_off,
|
Icons.cloud_off,
|
||||||
color: Colors.redAccent,
|
color: Colors.redAccent,
|
||||||
semanticLabel: 'Offline',
|
semanticLabel: l10n.offlineSemanticLabel,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Settings menu
|
// Settings menu
|
||||||
|
|
@ -614,17 +620,15 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSettingsMenu(WidgetRef ref) {
|
Widget _buildSettingsMenu(WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
final globalMode = ref.watch(readingModeNotifierProvider);
|
final globalMode = ref.watch(readingModeNotifierProvider);
|
||||||
final seriesOverride = widget.feedUrl != null
|
final seriesOverride = widget.feedUrl != null
|
||||||
? ref.watch(seriesReadingModeNotifierProvider(widget.feedUrl!))
|
? ref.watch(seriesReadingModeNotifierProvider(widget.feedUrl!))
|
||||||
: null;
|
: null;
|
||||||
final readingMode = seriesOverride ?? globalMode;
|
final readingMode = seriesOverride ?? globalMode;
|
||||||
|
|
||||||
final String readingModeLabel = switch (readingMode) {
|
final modeShortLabel = readingModeShortLabel(l10n, readingMode);
|
||||||
ReadingMode.ltr => 'LTR',
|
|
||||||
ReadingMode.rtl => 'RTL',
|
|
||||||
ReadingMode.verticalContinuous => 'Vertical',
|
|
||||||
};
|
|
||||||
|
|
||||||
final globalTwoPage = ref.watch(twoPageModeNotifierProvider);
|
final globalTwoPage = ref.watch(twoPageModeNotifierProvider);
|
||||||
final seriesToPageOverride = widget.feedUrl != null
|
final seriesToPageOverride = widget.feedUrl != null
|
||||||
|
|
@ -663,7 +667,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||||
? const Icon(Icons.check, size: 18)
|
? const Icon(Icons.check, size: 18)
|
||||||
: null,
|
: null,
|
||||||
onPressed: () => _setReadingMode(ref, ReadingMode.ltr),
|
onPressed: () => _setReadingMode(ref, ReadingMode.ltr),
|
||||||
child: const Text('Left to right'),
|
child: Text(l10n.readingModeLtr),
|
||||||
),
|
),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
leadingIcon: const Icon(Icons.arrow_back),
|
leadingIcon: const Icon(Icons.arrow_back),
|
||||||
|
|
@ -671,7 +675,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||||
? const Icon(Icons.check, size: 18)
|
? const Icon(Icons.check, size: 18)
|
||||||
: null,
|
: null,
|
||||||
onPressed: () => _setReadingMode(ref, ReadingMode.rtl),
|
onPressed: () => _setReadingMode(ref, ReadingMode.rtl),
|
||||||
child: const Text('Right to left'),
|
child: Text(l10n.readingModeRtl),
|
||||||
),
|
),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
leadingIcon: const Icon(Icons.swap_vert),
|
leadingIcon: const Icon(Icons.swap_vert),
|
||||||
|
|
@ -680,10 +684,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||||
: null,
|
: null,
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
_setReadingMode(ref, ReadingMode.verticalContinuous),
|
_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)
|
if (isPaged)
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
|
|
@ -697,7 +701,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||||
: TwoPageMode.auto;
|
: TwoPageMode.auto;
|
||||||
_setTwoPageMode(ref, newMode);
|
_setTwoPageMode(ref, newMode);
|
||||||
},
|
},
|
||||||
child: const Text('Two-page spread'),
|
child: Text(l10n.readersTwoPageSpread),
|
||||||
),
|
),
|
||||||
if (isPaged)
|
if (isPaged)
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
|
|
@ -705,13 +709,14 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||||
trailingIcon:
|
trailingIcon:
|
||||||
firstPageIsCover ? const Icon(Icons.check, size: 18) : null,
|
firstPageIsCover ? const Icon(Icons.check, size: 18) : null,
|
||||||
onPressed: () => _setCoverPage(ref, !firstPageIsCover),
|
onPressed: () => _setCoverPage(ref, !firstPageIsCover),
|
||||||
child: const Text('First page is cover'),
|
child: Text(l10n.readersFirstPageIsCover),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBottomBar(BuildContext context, OPDSStreamLink streamLink) {
|
Widget _buildBottomBar(BuildContext context, OPDSStreamLink streamLink) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final einkMode = ref.watch(einkModeNotifierProvider);
|
final einkMode = ref.watch(einkModeNotifierProvider);
|
||||||
final progress = (_currentPage + 1) / streamLink.pageCount;
|
final progress = (_currentPage + 1) / streamLink.pageCount;
|
||||||
final globalMode = ref.watch(readingModeNotifierProvider);
|
final globalMode = ref.watch(readingModeNotifierProvider);
|
||||||
|
|
@ -743,12 +748,13 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||||
if (spread.isPair) {
|
if (spread.isPair) {
|
||||||
final lo = spread.primaryPage + 1;
|
final lo = spread.primaryPage + 1;
|
||||||
final hi = spread.lastPage + 1;
|
final hi = spread.lastPage + 1;
|
||||||
pageIndicator = 'Pages $lo-$hi of ${streamLink.pageCount}';
|
pageIndicator = l10n.readerPagesOf(lo, hi, streamLink.pageCount);
|
||||||
} else {
|
} else {
|
||||||
pageIndicator = 'Page ${_currentPage + 1} of ${streamLink.pageCount}';
|
pageIndicator =
|
||||||
|
l10n.readerPageOf(_currentPage + 1, streamLink.pageCount);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pageIndicator = 'Page ${_currentPage + 1} of ${streamLink.pageCount}';
|
pageIndicator = l10n.readerPageOf(_currentPage + 1, streamLink.pageCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select active controller for navigation buttons
|
// Select active controller for navigation buttons
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/models/opds_server.dart';
|
import 'package:worldhopper/models/opds_server.dart';
|
||||||
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
||||||
import 'package:worldhopper/providers/server_provider.dart';
|
import 'package:worldhopper/providers/server_provider.dart';
|
||||||
|
|
@ -35,9 +36,11 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Add Server'),
|
title: Text(l10n.addServerTitle),
|
||||||
),
|
),
|
||||||
body: Form(
|
body: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
|
|
@ -47,15 +50,15 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
// Name field
|
// Name field
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Server Name',
|
labelText: l10n.addServerNameLabel,
|
||||||
hintText: 'My Library',
|
hintText: l10n.addServerNameHint,
|
||||||
prefixIcon: Icon(Icons.label),
|
prefixIcon: const Icon(Icons.label),
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.trim().isEmpty) {
|
if (value == null || value.trim().isEmpty) {
|
||||||
return 'Please enter a server name';
|
return l10n.addServerValidationNameRequired;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
@ -65,23 +68,23 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
// URL field
|
// URL field
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _urlController,
|
controller: _urlController,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Server URL',
|
labelText: l10n.addServerUrlLabel,
|
||||||
hintText: 'https://example.com/opds',
|
hintText: l10n.addServerUrlHint,
|
||||||
prefixIcon: Icon(Icons.link),
|
prefixIcon: const Icon(Icons.link),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.trim().isEmpty) {
|
if (value == null || value.trim().isEmpty) {
|
||||||
return 'Please enter a server URL';
|
return l10n.addServerValidationUrlRequired;
|
||||||
}
|
}
|
||||||
final uri = Uri.tryParse(value.trim());
|
final uri = Uri.tryParse(value.trim());
|
||||||
if (uri == null || !uri.hasScheme || !uri.hasAuthority) {
|
if (uri == null || !uri.hasScheme || !uri.hasAuthority) {
|
||||||
return 'Please enter a valid URL';
|
return l10n.addServerValidationUrlInvalid;
|
||||||
}
|
}
|
||||||
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||||
return 'URL must start with http:// or https://';
|
return l10n.addServerValidationUrlScheme;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
@ -90,8 +93,8 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
|
|
||||||
// Authentication toggle
|
// Authentication toggle
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
title: const Text('Requires Authentication'),
|
title: Text(l10n.addServerRequiresAuth),
|
||||||
subtitle: const Text('Enable if server requires login'),
|
subtitle: Text(l10n.addServerRequiresAuthSubtitle),
|
||||||
value: _requiresAuth,
|
value: _requiresAuth,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -109,15 +112,15 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
if (_requiresAuth) ...[
|
if (_requiresAuth) ...[
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _usernameController,
|
controller: _usernameController,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Username',
|
labelText: l10n.addServerUsername,
|
||||||
prefixIcon: Icon(Icons.person),
|
prefixIcon: const Icon(Icons.person),
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (_requiresAuth &&
|
if (_requiresAuth &&
|
||||||
(value == null || value.trim().isEmpty)) {
|
(value == null || value.trim().isEmpty)) {
|
||||||
return 'Please enter a username';
|
return l10n.addServerValidationUsernameRequired;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
@ -126,7 +129,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Password',
|
labelText: l10n.addServerPassword,
|
||||||
prefixIcon: const Icon(Icons.lock),
|
prefixIcon: const Icon(Icons.lock),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
|
|
@ -145,7 +148,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (_requiresAuth && (value == null || value.isEmpty)) {
|
if (_requiresAuth && (value == null || value.isEmpty)) {
|
||||||
return 'Please enter a password';
|
return l10n.addServerValidationPasswordRequired;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
@ -169,7 +172,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
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,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -188,7 +191,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
width: 20,
|
width: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Text('Add Server'),
|
: Text(l10n.addServerButton),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -206,6 +209,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final url = _urlController.text.trim();
|
final url = _urlController.text.trim();
|
||||||
|
|
||||||
// Check if server already exists
|
// Check if server already exists
|
||||||
|
|
@ -214,7 +218,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.showWarningSnackBar('A server with this URL already exists');
|
context.showWarningSnackBar(l10n.addServerDuplicate);
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
|
|
@ -236,12 +240,13 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
|
||||||
await ref.read(serverNotifierProvider.notifier).addServer(server);
|
await ref.read(serverNotifierProvider.notifier).addServer(server);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.showSuccessSnackBar('${server.name} added successfully');
|
context.showSuccessSnackBar(l10n.addServerSuccess(server.name));
|
||||||
context.pop();
|
context.pop();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.showErrorSnackBar('Error adding server: $e');
|
final l10n = AppLocalizations.of(context);
|
||||||
|
context.showErrorSnackBar(l10n.addServerError(e.toString()));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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/helpers/snackbar_helper.dart';
|
||||||
import 'package:worldhopper/models/opds_server.dart';
|
import 'package:worldhopper/models/opds_server.dart';
|
||||||
import 'package:worldhopper/providers/server_provider.dart';
|
import 'package:worldhopper/providers/server_provider.dart';
|
||||||
|
|
@ -40,17 +41,18 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final serverAsync = ref.watch(serverProvider(widget.serverId));
|
final serverAsync = ref.watch(serverProvider(widget.serverId));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Edit Server'),
|
title: Text(l10n.editServerTitle),
|
||||||
),
|
),
|
||||||
body: serverAsync.when(
|
body: serverAsync.when(
|
||||||
data: (server) {
|
data: (server) {
|
||||||
if (server == null) {
|
if (server == null) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: Text('Server not found'),
|
child: Text(l10n.editServerNotFound),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,14 +78,14 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
// Name field
|
// Name field
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Server Name',
|
labelText: l10n.addServerNameLabel,
|
||||||
prefixIcon: Icon(Icons.label),
|
prefixIcon: const Icon(Icons.label),
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.trim().isEmpty) {
|
if (value == null || value.trim().isEmpty) {
|
||||||
return 'Please enter a server name';
|
return l10n.addServerValidationNameRequired;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
@ -93,22 +95,22 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
// URL field
|
// URL field
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _urlController,
|
controller: _urlController,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Server URL',
|
labelText: l10n.addServerUrlLabel,
|
||||||
prefixIcon: Icon(Icons.link),
|
prefixIcon: const Icon(Icons.link),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.trim().isEmpty) {
|
if (value == null || value.trim().isEmpty) {
|
||||||
return 'Please enter a server URL';
|
return l10n.addServerValidationUrlRequired;
|
||||||
}
|
}
|
||||||
final uri = Uri.tryParse(value.trim());
|
final uri = Uri.tryParse(value.trim());
|
||||||
if (uri == null || !uri.hasScheme || !uri.hasAuthority) {
|
if (uri == null || !uri.hasScheme || !uri.hasAuthority) {
|
||||||
return 'Please enter a valid URL';
|
return l10n.addServerValidationUrlInvalid;
|
||||||
}
|
}
|
||||||
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||||
return 'URL must start with http:// or https://';
|
return l10n.addServerValidationUrlScheme;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
@ -117,8 +119,8 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
|
|
||||||
// Authentication toggle
|
// Authentication toggle
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
title: const Text('Requires Authentication'),
|
title: Text(l10n.addServerRequiresAuth),
|
||||||
subtitle: const Text('Enable if server requires login'),
|
subtitle: Text(l10n.addServerRequiresAuthSubtitle),
|
||||||
value: _requiresAuth,
|
value: _requiresAuth,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -136,15 +138,15 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
if (_requiresAuth) ...[
|
if (_requiresAuth) ...[
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _usernameController,
|
controller: _usernameController,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Username',
|
labelText: l10n.addServerUsername,
|
||||||
prefixIcon: Icon(Icons.person),
|
prefixIcon: const Icon(Icons.person),
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (_requiresAuth &&
|
if (_requiresAuth &&
|
||||||
(value == null || value.trim().isEmpty)) {
|
(value == null || value.trim().isEmpty)) {
|
||||||
return 'Please enter a username';
|
return l10n.addServerValidationUsernameRequired;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
@ -153,7 +155,7 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Password',
|
labelText: l10n.addServerPassword,
|
||||||
prefixIcon: const Icon(Icons.lock),
|
prefixIcon: const Icon(Icons.lock),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
|
|
@ -172,7 +174,7 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (_requiresAuth && (value == null || value.isEmpty)) {
|
if (_requiresAuth && (value == null || value.isEmpty)) {
|
||||||
return 'Please enter a password';
|
return l10n.addServerValidationPasswordRequired;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
@ -190,7 +192,7 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Server Information',
|
l10n.editServerInfo,
|
||||||
style:
|
style:
|
||||||
Theme.of(context).textTheme.titleSmall?.copyWith(
|
Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -198,12 +200,12 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
'Added',
|
l10n.editServerAdded,
|
||||||
_formatDateTime(server.createdAt),
|
_formatDateTime(server.createdAt),
|
||||||
),
|
),
|
||||||
if (server.lastSyncedAt != null)
|
if (server.lastSyncedAt != null)
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
'Last Synced',
|
l10n.editServerLastSynced,
|
||||||
_formatDateTime(server.lastSyncedAt!),
|
_formatDateTime(server.lastSyncedAt!),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -221,7 +223,7 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
width: 20,
|
width: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Text('Save Changes'),
|
: Text(l10n.editServerSaveButton),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -234,7 +236,7 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('Error loading server: $error'),
|
Text(l10n.editServerErrorLoading(error.toString())),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -281,6 +283,8 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
// Create updated server
|
// Create updated server
|
||||||
final updatedServer = _originalServer!.copyWith(
|
final updatedServer = _originalServer!.copyWith(
|
||||||
name: _nameController.text.trim(),
|
name: _nameController.text.trim(),
|
||||||
|
|
@ -295,13 +299,13 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
|
||||||
.updateServer(updatedServer);
|
.updateServer(updatedServer);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context
|
context.showSuccessSnackBar(l10n.editServerSuccess(updatedServer.name));
|
||||||
.showSuccessSnackBar('${updatedServer.name} updated successfully');
|
|
||||||
context.pop();
|
context.pop();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.showErrorSnackBar('Error updating server: $e');
|
final l10n = AppLocalizations.of(context);
|
||||||
|
context.showErrorSnackBar(l10n.editServerError(e.toString()));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/config/constants.dart';
|
import 'package:worldhopper/config/constants.dart';
|
||||||
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
||||||
import 'package:worldhopper/providers/server_provider.dart';
|
import 'package:worldhopper/providers/server_provider.dart';
|
||||||
|
|
@ -14,16 +15,17 @@ class ServerListScreen extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final serversAsync = ref.watch(serverListProvider);
|
final serversAsync = ref.watch(serverListProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: const WorldhopperAppBar(
|
appBar: WorldhopperAppBar(
|
||||||
title: Text('My Servers'),
|
title: Text(l10n.serverListTitle),
|
||||||
),
|
),
|
||||||
body: serversAsync.when(
|
body: serversAsync.when(
|
||||||
data: (servers) {
|
data: (servers) {
|
||||||
if (servers.isEmpty) {
|
if (servers.isEmpty) {
|
||||||
return _buildEmptyState(context);
|
return _buildEmptyState(context, l10n);
|
||||||
}
|
}
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
|
|
@ -49,7 +51,7 @@ class ServerListScreen extends ConsumerWidget {
|
||||||
if (!isOnline) {
|
if (!isOnline) {
|
||||||
// Block navigation when offline
|
// Block navigation when offline
|
||||||
context.showInfoSnackBar(
|
context.showInfoSnackBar(
|
||||||
'You are offline. Connect to the internet to browse servers.',
|
l10n.serverListOffline,
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|
@ -65,13 +67,14 @@ class ServerListScreen extends ConsumerWidget {
|
||||||
},
|
},
|
||||||
onDelete: () async {
|
onDelete: () async {
|
||||||
final confirmed =
|
final confirmed =
|
||||||
await _confirmDelete(context, server.name);
|
await _confirmDelete(context, l10n, server.name);
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
await ref
|
await ref
|
||||||
.read(serverNotifierProvider.notifier)
|
.read(serverNotifierProvider.notifier)
|
||||||
.deleteServer(server.id);
|
.deleteServer(server.id);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.showInfoSnackBar('${server.name} deleted');
|
context
|
||||||
|
.showInfoSnackBar(l10n.serverDeleted(server.name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -87,11 +90,11 @@ class ServerListScreen extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('Error loading servers: $error'),
|
Text(l10n.serverListErrorLoading(error.toString())),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => ref.invalidate(serverListProvider),
|
onPressed: () => ref.invalidate(serverListProvider),
|
||||||
child: const Text('Retry'),
|
child: Text(l10n.serverListRetry),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -102,12 +105,12 @@ class ServerListScreen extends ConsumerWidget {
|
||||||
context.push(AppConstants.routeAddServer);
|
context.push(AppConstants.routeAddServer);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.add),
|
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(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
|
@ -120,14 +123,14 @@ class ServerListScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'No Servers Yet',
|
l10n.serverListEmpty,
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Add your first OPDS server to start browsing and reading',
|
l10n.serverListEmptySubtitle,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(context)
|
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>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Delete Server'),
|
title: Text(l10n.serverDeleteTitle),
|
||||||
content: Text('Are you sure you want to delete "$serverName"?'),
|
content: Text(l10n.serverDeleteConfirmation(serverName)),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
child: const Text('Cancel'),
|
child: Text(l10n.commonCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
),
|
),
|
||||||
child: const Text('Delete'),
|
child: Text(l10n.commonDelete),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:worldhopper/providers/package_info_provider.dart';
|
import 'package:worldhopper/providers/package_info_provider.dart';
|
||||||
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
|
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
|
||||||
|
|
@ -12,11 +13,12 @@ class AboutScreen extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final packageInfoAsync = ref.watch(packageInfoProvider);
|
final packageInfoAsync = ref.watch(packageInfoProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: const WorldhopperAppBar(
|
appBar: WorldhopperAppBar(
|
||||||
title: Text('About'),
|
title: Text(l10n.aboutTitle),
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -27,17 +29,17 @@ class AboutScreen extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.label_outline),
|
leading: const Icon(Icons.label_outline),
|
||||||
title: const Text('Version'),
|
title: Text(l10n.aboutVersion),
|
||||||
subtitle: Text(packageInfoAsync.when(
|
subtitle: Text(packageInfoAsync.when(
|
||||||
data: (info) => info.version,
|
data: (info) => info.version,
|
||||||
loading: () => '...',
|
loading: () => '...',
|
||||||
error: (_, __) => 'unknown',
|
error: (_, __) => l10n.aboutVersionUnknown,
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.code),
|
leading: const Icon(Icons.code),
|
||||||
title: const Text('Source code'),
|
title: Text(l10n.aboutSourceCode),
|
||||||
subtitle: const Text(_repoUrl),
|
subtitle: const Text(_repoUrl),
|
||||||
trailing: const Icon(Icons.open_in_new),
|
trailing: const Icon(Icons.open_in_new),
|
||||||
onTap: () => launchUrl(Uri.parse(_repoUrl),
|
onTap: () => launchUrl(Uri.parse(_repoUrl),
|
||||||
|
|
@ -46,8 +48,8 @@ class AboutScreen extends ConsumerWidget {
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.bug_report_outlined),
|
leading: const Icon(Icons.bug_report_outlined),
|
||||||
title: const Text('Report an issue'),
|
title: Text(l10n.aboutReportIssue),
|
||||||
subtitle: const Text('Open an issue on the repository'),
|
subtitle: Text(l10n.aboutReportIssueSubtitle),
|
||||||
trailing: const Icon(Icons.open_in_new),
|
trailing: const Icon(Icons.open_in_new),
|
||||||
onTap: () => launchUrl(Uri.parse('$_repoUrl/issues'),
|
onTap: () => launchUrl(Uri.parse('$_repoUrl/issues'),
|
||||||
mode: LaunchMode.externalApplication),
|
mode: LaunchMode.externalApplication),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/config/constants.dart';
|
import 'package:worldhopper/config/constants.dart';
|
||||||
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
||||||
import 'package:worldhopper/providers/developer_mode_provider.dart';
|
import 'package:worldhopper/providers/developer_mode_provider.dart';
|
||||||
|
|
@ -16,12 +17,13 @@ class AdvancedSettingsScreen extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final packageInfoAsync = ref.watch(packageInfoProvider);
|
final packageInfoAsync = ref.watch(packageInfoProvider);
|
||||||
final isDeveloperMode = ref.watch(developerModeNotifierProvider);
|
final isDeveloperMode = ref.watch(developerModeNotifierProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: const WorldhopperAppBar(
|
appBar: WorldhopperAppBar(
|
||||||
title: Text('Advanced'),
|
title: Text(l10n.advancedTitle),
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -32,8 +34,8 @@ class AdvancedSettingsScreen extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
secondary: const Icon(Icons.developer_mode),
|
secondary: const Icon(Icons.developer_mode),
|
||||||
title: const Text('Developer mode'),
|
title: Text(l10n.advancedDeveloperMode),
|
||||||
subtitle: const Text('Enable debugging tools'),
|
subtitle: Text(l10n.advancedDeveloperModeSubtitle),
|
||||||
value: isDeveloperMode,
|
value: isDeveloperMode,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
ref
|
ref
|
||||||
|
|
@ -45,8 +47,8 @@ class AdvancedSettingsScreen extends ConsumerWidget {
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.science_outlined),
|
leading: const Icon(Icons.science_outlined),
|
||||||
title: const Text('Tests'),
|
title: Text(l10n.advancedTests),
|
||||||
subtitle: const Text('Preview UI components'),
|
subtitle: Text(l10n.advancedTestsSubtitle),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
|
|
@ -60,7 +62,7 @@ class AdvancedSettingsScreen extends ConsumerWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildSectionHeader(context, 'Debugging info'),
|
_buildSectionHeader(context, l10n.advancedDebuggingInfo),
|
||||||
Card(
|
Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: packageInfoAsync.when(
|
child: packageInfoAsync.when(
|
||||||
|
|
@ -71,36 +73,35 @@ class AdvancedSettingsScreen extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.label_outline),
|
leading: const Icon(Icons.label_outline),
|
||||||
title: const Text('Version'),
|
title: Text(l10n.advancedVersion),
|
||||||
subtitle: Text(info.version),
|
subtitle: Text(info.version),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.build_outlined),
|
leading: const Icon(Icons.build_outlined),
|
||||||
title: const Text('Build number'),
|
title: Text(l10n.advancedBuildNumber),
|
||||||
subtitle: Text(info.buildNumber),
|
subtitle: Text(info.buildNumber),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.inventory_2_outlined),
|
leading: const Icon(Icons.inventory_2_outlined),
|
||||||
title: const Text('Package name'),
|
title: Text(l10n.advancedPackageName),
|
||||||
subtitle: Text(info.packageName),
|
subtitle: Text(info.packageName),
|
||||||
),
|
),
|
||||||
const ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.storage_outlined),
|
leading: const Icon(Icons.storage_outlined),
|
||||||
title: Text('Database version'),
|
title: Text(l10n.advancedDatabaseVersion),
|
||||||
subtitle: Text('${AppConstants.databaseVersion}'),
|
subtitle: const Text('${AppConstants.databaseVersion}'),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Platform.isIOS
|
leading: Icon(Platform.isIOS
|
||||||
? Icons.phone_iphone
|
? Icons.phone_iphone
|
||||||
: Icons.phone_android),
|
: Icons.phone_android),
|
||||||
title: const Text('Platform'),
|
title: Text(l10n.advancedPlatform),
|
||||||
subtitle: Text(platform),
|
subtitle: Text(platform),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.copy),
|
leading: const Icon(Icons.copy),
|
||||||
title: const Text('Copy to clipboard'),
|
title: Text(l10n.advancedCopyToClipboard),
|
||||||
subtitle:
|
subtitle: Text(l10n.advancedCopyToClipboardSubtitle),
|
||||||
const Text('Copy all debug info for bug reports'),
|
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final debugInfo = StringBuffer()
|
final debugInfo = StringBuffer()
|
||||||
..writeln('Version: ${info.version}')
|
..writeln('Version: ${info.version}')
|
||||||
|
|
@ -111,8 +112,7 @@ class AdvancedSettingsScreen extends ConsumerWidget {
|
||||||
..writeln('Platform: $platform');
|
..writeln('Platform: $platform');
|
||||||
Clipboard.setData(
|
Clipboard.setData(
|
||||||
ClipboardData(text: debugInfo.toString().trim()));
|
ClipboardData(text: debugInfo.toString().trim()));
|
||||||
context
|
context.showInfoSnackBar(l10n.advancedDebugInfoCopied);
|
||||||
.showInfoSnackBar('Debug info copied to clipboard');
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -124,7 +124,7 @@ class AdvancedSettingsScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
error: (error, _) => ListTile(
|
error: (error, _) => ListTile(
|
||||||
leading: const Icon(Icons.error_outline),
|
leading: const Icon(Icons.error_outline),
|
||||||
title: const Text('Failed to load info'),
|
title: Text(l10n.advancedFailedToLoadInfo),
|
||||||
subtitle: Text('$error'),
|
subtitle: Text('$error'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/providers/theme_provider.dart';
|
||||||
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
|
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
|
||||||
|
|
||||||
|
|
@ -9,15 +10,16 @@ class AppearanceSettingsScreen extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final themeMode = ref.watch(themeNotifierProvider);
|
final themeMode = ref.watch(themeNotifierProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: const WorldhopperAppBar(
|
appBar: WorldhopperAppBar(
|
||||||
title: Text('Appearance'),
|
title: Text(l10n.appearanceTitle),
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
_buildSectionHeader(context, 'Theme'),
|
_buildSectionHeader(context, l10n.appearanceTheme),
|
||||||
Card(
|
Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: RadioGroup<ThemeMode>(
|
child: RadioGroup<ThemeMode>(
|
||||||
|
|
@ -31,7 +33,7 @@ class AppearanceSettingsScreen extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.light_mode),
|
leading: const Icon(Icons.light_mode),
|
||||||
title: const Text('Light'),
|
title: Text(l10n.themeLight),
|
||||||
trailing: const Radio<ThemeMode>(
|
trailing: const Radio<ThemeMode>(
|
||||||
value: ThemeMode.light,
|
value: ThemeMode.light,
|
||||||
),
|
),
|
||||||
|
|
@ -44,7 +46,7 @@ class AppearanceSettingsScreen extends ConsumerWidget {
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.dark_mode),
|
leading: const Icon(Icons.dark_mode),
|
||||||
title: const Text('Dark'),
|
title: Text(l10n.themeDark),
|
||||||
trailing: const Radio<ThemeMode>(
|
trailing: const Radio<ThemeMode>(
|
||||||
value: ThemeMode.dark,
|
value: ThemeMode.dark,
|
||||||
),
|
),
|
||||||
|
|
@ -57,8 +59,8 @@ class AppearanceSettingsScreen extends ConsumerWidget {
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.brightness_auto),
|
leading: const Icon(Icons.brightness_auto),
|
||||||
title: const Text('System'),
|
title: Text(l10n.themeSystem),
|
||||||
subtitle: const Text('Follow device theme'),
|
subtitle: Text(l10n.themeSystemSubtitle),
|
||||||
trailing: const Radio<ThemeMode>(
|
trailing: const Radio<ThemeMode>(
|
||||||
value: ThemeMode.system,
|
value: ThemeMode.system,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
||||||
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
|
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
|
||||||
|
|
||||||
|
|
@ -8,44 +9,46 @@ class DevTestsScreen extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: const WorldhopperAppBar(
|
appBar: WorldhopperAppBar(
|
||||||
title: Text('Dev Tests'),
|
title: Text(l10n.devTestsTitle),
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
_buildSectionHeader(context, 'Snack Bars'),
|
_buildSectionHeader(context, l10n.devTestsSnackBars),
|
||||||
Card(
|
Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.check_circle, color: Colors.green),
|
leading: const Icon(Icons.check_circle, color: Colors.green),
|
||||||
title: const Text('Success'),
|
title: Text(l10n.devTestsSuccess),
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
context.showSuccessSnackBar('This is a success message'),
|
context.showSuccessSnackBar(l10n.devTestsSuccessMessage),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.error,
|
leading: Icon(Icons.error,
|
||||||
color: Theme.of(context).colorScheme.error),
|
color: Theme.of(context).colorScheme.error),
|
||||||
title: const Text('Error'),
|
title: Text(l10n.devTestsError),
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
context.showErrorSnackBar('This is an error message'),
|
context.showErrorSnackBar(l10n.devTestsErrorMessage),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.warning, color: Colors.orange),
|
leading: const Icon(Icons.warning, color: Colors.orange),
|
||||||
title: const Text('Warning'),
|
title: Text(l10n.devTestsWarning),
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
context.showWarningSnackBar('This is a warning message'),
|
context.showWarningSnackBar(l10n.devTestsWarningMessage),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.info_outline),
|
leading: const Icon(Icons.info_outline),
|
||||||
title: const Text('Info'),
|
title: Text(l10n.devTestsInfo),
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
context.showInfoSnackBar('This is an info message'),
|
context.showInfoSnackBar(l10n.devTestsInfoMessage),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/providers/filter_quality_provider.dart';
|
||||||
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
|
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
|
||||||
|
|
||||||
|
|
@ -9,11 +10,35 @@ class FilterQualitySettingsScreen extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final filterQuality = ref.watch(filterQualityNotifierProvider);
|
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(
|
return Scaffold(
|
||||||
appBar: const WorldhopperAppBar(
|
appBar: WorldhopperAppBar(
|
||||||
title: Text('Filter Quality'),
|
title: Text(l10n.filterQualityTitle),
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -31,8 +56,8 @@ class FilterQualitySettingsScreen extends ConsumerWidget {
|
||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
for (final entry in _filterQualityOptions.entries) ...[
|
for (final entry in options.entries) ...[
|
||||||
if (entry.key != _filterQualityOptions.keys.first)
|
if (entry.key != options.keys.first)
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(entry.value.icon),
|
leading: Icon(entry.value.icon),
|
||||||
|
|
@ -55,9 +80,7 @@ class FilterQualitySettingsScreen extends ConsumerWidget {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(32, 16, 32, 16),
|
padding: const EdgeInsets.fromLTRB(32, 16, 32, 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Controls how images are scaled when a page is zoomed out to fit '
|
l10n.filterQualityHelp,
|
||||||
'the screen. Higher quality makes text and fine details sharper '
|
|
||||||
'but uses more GPU resources. Medium is recommended for most devices.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|
@ -80,26 +103,3 @@ class _FilterQualityOption {
|
||||||
required this.icon,
|
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,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
|
||||||
85
lib/screens/settings/language_settings_screen.dart
Normal file
85
lib/screens/settings/language_settings_screen.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/providers/precache_pages_provider.dart';
|
||||||
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
|
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
|
||||||
|
|
||||||
|
|
@ -9,11 +10,45 @@ class PrecachePagesSettingsScreen extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final precachePages = ref.watch(precachePagesNotifierProvider);
|
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(
|
return Scaffold(
|
||||||
appBar: const WorldhopperAppBar(
|
appBar: WorldhopperAppBar(
|
||||||
title: Text('Pre-cache Pages'),
|
title: Text(l10n.precachePagesTitle),
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -31,8 +66,8 @@ class PrecachePagesSettingsScreen extends ConsumerWidget {
|
||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
for (final entry in _precachePagesOptions.entries) ...[
|
for (final entry in options.entries) ...[
|
||||||
if (entry.key != _precachePagesOptions.keys.first)
|
if (entry.key != options.keys.first)
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(entry.value.icon),
|
leading: Icon(entry.value.icon),
|
||||||
|
|
@ -55,9 +90,7 @@ class PrecachePagesSettingsScreen extends ConsumerWidget {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(32, 16, 32, 16),
|
padding: const EdgeInsets.fromLTRB(32, 16, 32, 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Controls how many upcoming pages are pre-loaded in the background '
|
l10n.precachePagesHelp,
|
||||||
'while reading. Higher values make swiping feel more seamless but '
|
|
||||||
'use more bandwidth and memory.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|
@ -80,36 +113,3 @@ class _PrecachePagesOption {
|
||||||
required this.icon,
|
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,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/eink_mode_provider.dart';
|
||||||
import 'package:worldhopper/providers/filter_quality_provider.dart';
|
import 'package:worldhopper/providers/filter_quality_provider.dart';
|
||||||
import 'package:worldhopper/providers/precache_pages_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 {
|
class ReadersSettingsScreen extends ConsumerWidget {
|
||||||
const ReadersSettingsScreen({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final einkMode = ref.watch(einkModeNotifierProvider);
|
final einkMode = ref.watch(einkModeNotifierProvider);
|
||||||
final readingMode = ref.watch(readingModeNotifierProvider);
|
final readingMode = ref.watch(readingModeNotifierProvider);
|
||||||
final filterQuality = ref.watch(filterQualityNotifierProvider);
|
final filterQuality = ref.watch(filterQualityNotifierProvider);
|
||||||
|
|
@ -47,20 +28,20 @@ class ReadersSettingsScreen extends ConsumerWidget {
|
||||||
final firstPageIsCover = ref.watch(coverPageNotifierProvider);
|
final firstPageIsCover = ref.watch(coverPageNotifierProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: const WorldhopperAppBar(
|
appBar: WorldhopperAppBar(
|
||||||
title: Text('Readers'),
|
title: Text(l10n.readersTitle),
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
_buildSectionHeader(context, 'General'),
|
_buildSectionHeader(context, l10n.readersGeneralSection),
|
||||||
Card(
|
Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
secondary: const Icon(Icons.tablet_android),
|
secondary: const Icon(Icons.tablet_android),
|
||||||
title: const Text('E-ink mode'),
|
title: Text(l10n.readersEinkMode),
|
||||||
subtitle: const Text('Disable animations for e-ink displays'),
|
subtitle: Text(l10n.readersEinkModeSubtitle),
|
||||||
value: einkMode,
|
value: einkMode,
|
||||||
onChanged: (enabled) {
|
onChanged: (enabled) {
|
||||||
ref
|
ref
|
||||||
|
|
@ -71,16 +52,15 @@ class ReadersSettingsScreen extends ConsumerWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildSectionHeader(context, 'Image Reader'),
|
_buildSectionHeader(context, l10n.readersImageReaderSection),
|
||||||
Card(
|
Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.chrome_reader_mode_outlined),
|
leading: const Icon(Icons.chrome_reader_mode_outlined),
|
||||||
title: const Text('Reading direction'),
|
title: Text(l10n.readersReadingDirection),
|
||||||
subtitle:
|
subtitle: Text(readingModeLabel(l10n, readingMode)),
|
||||||
Text(_readingModeLabels[readingMode] ?? 'Left to right'),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
|
|
@ -93,9 +73,8 @@ class ReadersSettingsScreen extends ConsumerWidget {
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.high_quality_outlined),
|
leading: const Icon(Icons.high_quality_outlined),
|
||||||
title: const Text('Filter quality'),
|
title: Text(l10n.readersFilterQuality),
|
||||||
subtitle:
|
subtitle: Text(filterQualityLabel(l10n, filterQuality)),
|
||||||
Text(_filterQualityLabels[filterQuality] ?? 'Medium'),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
|
|
@ -108,9 +87,8 @@ class ReadersSettingsScreen extends ConsumerWidget {
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.cached_outlined),
|
leading: const Icon(Icons.cached_outlined),
|
||||||
title: const Text('Pre-cache pages'),
|
title: Text(l10n.readersPrecachePages),
|
||||||
subtitle: Text(_precachePagesLabels[precachePages] ??
|
subtitle: Text(precachePagesLabel(l10n, precachePages)),
|
||||||
'$precachePages pages'),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
|
|
@ -123,9 +101,8 @@ class ReadersSettingsScreen extends ConsumerWidget {
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
secondary: const Icon(Icons.auto_stories),
|
secondary: const Icon(Icons.auto_stories),
|
||||||
title: const Text('Two-page spread'),
|
title: Text(l10n.readersTwoPageSpread),
|
||||||
subtitle:
|
subtitle: Text(l10n.readersTwoPageSpreadSubtitle),
|
||||||
const Text('Show two pages side by side on wide screens'),
|
|
||||||
value: twoPageMode == TwoPageMode.auto,
|
value: twoPageMode == TwoPageMode.auto,
|
||||||
onChanged: (enabled) {
|
onChanged: (enabled) {
|
||||||
ref
|
ref
|
||||||
|
|
@ -137,9 +114,8 @@ class ReadersSettingsScreen extends ConsumerWidget {
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
secondary: const Icon(Icons.looks_one),
|
secondary: const Icon(Icons.looks_one),
|
||||||
title: const Text('First page is cover'),
|
title: Text(l10n.readersFirstPageIsCover),
|
||||||
subtitle: const Text(
|
subtitle: Text(l10n.readersFirstPageIsCoverSubtitle),
|
||||||
'Show the first page alone in two-page spread'),
|
|
||||||
value: firstPageIsCover,
|
value: firstPageIsCover,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
ref
|
ref
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/providers/reading_mode_provider.dart';
|
||||||
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
|
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
|
||||||
|
|
||||||
|
|
@ -9,11 +10,30 @@ class ReadingModeSettingsScreen extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final readingMode = ref.watch(readingModeNotifierProvider);
|
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(
|
return Scaffold(
|
||||||
appBar: const WorldhopperAppBar(
|
appBar: WorldhopperAppBar(
|
||||||
title: Text('Reading Direction'),
|
title: Text(l10n.readingDirectionTitle),
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -31,8 +51,8 @@ class ReadingModeSettingsScreen extends ConsumerWidget {
|
||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
for (final entry in _readingModeOptions.entries) ...[
|
for (final entry in options.entries) ...[
|
||||||
if (entry.key != _readingModeOptions.keys.first)
|
if (entry.key != options.keys.first)
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(entry.value.icon),
|
leading: Icon(entry.value.icon),
|
||||||
|
|
@ -55,9 +75,7 @@ class ReadingModeSettingsScreen extends ConsumerWidget {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(32, 16, 32, 16),
|
padding: const EdgeInsets.fromLTRB(32, 16, 32, 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Controls the reading direction for the image reader. '
|
l10n.readingDirectionHelp,
|
||||||
'Left to right is standard for Western comics, right to left '
|
|
||||||
'for manga, and vertical scroll for webtoons.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|
@ -80,21 +98,3 @@ class _ReadingModeOption {
|
||||||
required this.icon,
|
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,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/about_screen.dart';
|
||||||
import 'package:worldhopper/screens/settings/advanced_settings_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/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/screens/settings/readers_settings_screen.dart';
|
||||||
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
|
import 'package:worldhopper/widgets/worldhopper_app_bar.dart';
|
||||||
|
|
||||||
|
|
@ -12,9 +14,11 @@ class SettingsScreen extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: const WorldhopperAppBar(
|
appBar: WorldhopperAppBar(
|
||||||
title: Text('Settings'),
|
title: Text(l10n.settingsTitle),
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -25,8 +29,8 @@ class SettingsScreen extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.palette_outlined),
|
leading: const Icon(Icons.palette_outlined),
|
||||||
title: const Text('Appearance'),
|
title: Text(l10n.settingsAppearance),
|
||||||
subtitle: const Text('Display options'),
|
subtitle: Text(l10n.settingsAppearanceSubtitle),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
|
|
@ -37,10 +41,24 @@ class SettingsScreen extends ConsumerWidget {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
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(
|
ListTile(
|
||||||
leading: const Icon(Icons.menu_book_outlined),
|
leading: const Icon(Icons.menu_book_outlined),
|
||||||
title: const Text('Readers'),
|
title: Text(l10n.settingsReaders),
|
||||||
subtitle: const Text('Reader options'),
|
subtitle: Text(l10n.settingsReadersSubtitle),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
|
|
@ -53,8 +71,8 @@ class SettingsScreen extends ConsumerWidget {
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.tune_outlined),
|
leading: const Icon(Icons.tune_outlined),
|
||||||
title: const Text('Advanced'),
|
title: Text(l10n.settingsAdvanced),
|
||||||
subtitle: const Text('Developer and debugging'),
|
subtitle: Text(l10n.settingsAdvancedSubtitle),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
|
|
@ -67,8 +85,8 @@ class SettingsScreen extends ConsumerWidget {
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.info_outline),
|
leading: const Icon(Icons.info_outline),
|
||||||
title: const Text('About'),
|
title: Text(l10n.settingsAbout),
|
||||||
subtitle: const Text('Worldhopper'),
|
subtitle: Text(l10n.settingsAboutSubtitle),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/providers/navigation_provider.dart';
|
import 'package:worldhopper/providers/navigation_provider.dart';
|
||||||
|
|
||||||
/// Shell widget that provides the bottom navigation bar
|
/// Shell widget that provides the bottom navigation bar
|
||||||
|
|
@ -50,26 +51,28 @@ class _MainShellScreenState extends ConsumerState<MainShellScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: widget.navigationShell,
|
body: widget.navigationShell,
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: widget.navigationShell.currentIndex,
|
selectedIndex: widget.navigationShell.currentIndex,
|
||||||
onDestinationSelected: _onDestinationSelected,
|
onDestinationSelected: _onDestinationSelected,
|
||||||
destinations: const [
|
destinations: [
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.auto_stories_outlined),
|
icon: const Icon(Icons.auto_stories_outlined),
|
||||||
selectedIcon: Icon(Icons.auto_stories),
|
selectedIcon: const Icon(Icons.auto_stories),
|
||||||
label: 'Library',
|
label: l10n.navLibrary,
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.dns_outlined),
|
icon: const Icon(Icons.dns_outlined),
|
||||||
selectedIcon: Icon(Icons.dns),
|
selectedIcon: const Icon(Icons.dns),
|
||||||
label: 'Servers',
|
label: l10n.navServers,
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.settings_outlined),
|
icon: const Icon(Icons.settings_outlined),
|
||||||
selectedIcon: Icon(Icons.settings),
|
selectedIcon: const Icon(Icons.settings),
|
||||||
label: 'Settings',
|
label: l10n.navSettings,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/config/constants.dart';
|
import 'package:worldhopper/config/constants.dart';
|
||||||
import 'package:worldhopper/models/opds_entry.dart';
|
import 'package:worldhopper/models/opds_entry.dart';
|
||||||
import 'package:worldhopper/providers/connectivity_provider.dart';
|
import 'package:worldhopper/providers/connectivity_provider.dart';
|
||||||
|
|
@ -83,7 +84,7 @@ class _NextInSeriesOverlayState extends ConsumerState<NextInSeriesOverlay> {
|
||||||
onColor: Theme.of(context).colorScheme.onPrimaryContainer,
|
onColor: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
onTap: () => _navigateToNext(context, nextEntry),
|
onTap: () => _navigateToNext(context, nextEntry),
|
||||||
icon: Icons.skip_next,
|
icon: Icons.skip_next,
|
||||||
label: 'Next',
|
label: AppLocalizations.of(context).nextInSeriesNext,
|
||||||
title: nextEntry.title,
|
title: nextEntry.title,
|
||||||
trailingIcon: Icons.arrow_forward,
|
trailingIcon: Icons.arrow_forward,
|
||||||
);
|
);
|
||||||
|
|
@ -94,6 +95,7 @@ class _NextInSeriesOverlayState extends ConsumerState<NextInSeriesOverlay> {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
Widget _buildFinishedOverlay(BuildContext context) {
|
Widget _buildFinishedOverlay(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final connectivityState = ref.watch(connectivityStateProvider);
|
final connectivityState = ref.watch(connectivityStateProvider);
|
||||||
final isOnline = connectivityState.whenOrNull(
|
final isOnline = connectivityState.whenOrNull(
|
||||||
data: (online) => online,
|
data: (online) => online,
|
||||||
|
|
@ -106,8 +108,10 @@ class _NextInSeriesOverlayState extends ConsumerState<NextInSeriesOverlay> {
|
||||||
onColor: Theme.of(context).colorScheme.onSecondaryContainer,
|
onColor: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||||
onTap: () => _navigateBack(context, isOnline),
|
onTap: () => _navigateBack(context, isOnline),
|
||||||
icon: Icons.check_circle_outline,
|
icon: Icons.check_circle_outline,
|
||||||
label: 'Finished',
|
label: l10n.nextInSeriesFinished,
|
||||||
title: isOnline ? 'Back to details' : 'Back to library',
|
title: isOnline
|
||||||
|
? l10n.nextInSeriesBackToDetails
|
||||||
|
: l10n.nextInSeriesBackToLibrary,
|
||||||
trailingIcon: Icons.arrow_back,
|
trailingIcon: Icons.arrow_back,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/models/opds_entry.dart';
|
import 'package:worldhopper/models/opds_entry.dart';
|
||||||
|
|
||||||
/// Card widget displaying a publication or navigation entry
|
/// Card widget displaying a publication or navigation entry
|
||||||
|
|
@ -17,6 +18,8 @@ class PublicationCard extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
|
|
@ -68,7 +71,7 @@ class PublicationCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'${entry.streamLink!.pageCount} pages',
|
l10n.publicationPages(entry.streamLink!.pageCount),
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodySmall
|
.bodySmall
|
||||||
|
|
@ -90,7 +93,7 @@ class PublicationCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'Collection',
|
l10n.publicationCollection,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodySmall
|
.bodySmall
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/config/constants.dart';
|
import 'package:worldhopper/config/constants.dart';
|
||||||
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
import 'package:worldhopper/helpers/snackbar_helper.dart';
|
||||||
import 'package:worldhopper/models/reading_progress.dart';
|
import 'package:worldhopper/models/reading_progress.dart';
|
||||||
|
|
@ -25,6 +26,8 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
// Load cached publication
|
// Load cached publication
|
||||||
final publicationAsync = ref.watch(
|
final publicationAsync = ref.watch(
|
||||||
cachedPublicationProvider(progress.serverId, progress.publicationId),
|
cachedPublicationProvider(progress.serverId, progress.publicationId),
|
||||||
|
|
@ -39,7 +42,7 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Publication not found',
|
l10n.recentlyReadPublicationNotFound,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -121,7 +124,7 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
|
|
||||||
// Progress and date
|
// Progress and date
|
||||||
Text(
|
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(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
|
|
@ -145,7 +148,7 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Error loading publication',
|
l10n.recentlyReadErrorLoading,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -207,6 +210,8 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
dynamic publication,
|
dynamic publication,
|
||||||
) async {
|
) async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
// Convert to OPDSEntry
|
// Convert to OPDSEntry
|
||||||
final entry = publication.toOPDSEntry();
|
final entry = publication.toOPDSEntry();
|
||||||
|
|
||||||
|
|
@ -227,7 +232,7 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
// Block EPUBs that aren't cached
|
// Block EPUBs that aren't cached
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
context.showInfoSnackBar(
|
context.showInfoSnackBar(
|
||||||
'This EPUB is not cached. Connect to internet to download.',
|
l10n.recentlyReadOfflineEpubNotCached,
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
);
|
);
|
||||||
return; // Block navigation
|
return; // Block navigation
|
||||||
|
|
@ -235,7 +240,7 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
// Warn for OPDS-PS but allow navigation
|
// Warn for OPDS-PS but allow navigation
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
context.showInfoSnackBar(
|
context.showInfoSnackBar(
|
||||||
'You are offline. Pages may not load.',
|
l10n.recentlyReadOfflinePagesWarning,
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
);
|
);
|
||||||
// Continue to navigation
|
// Continue to navigation
|
||||||
|
|
@ -268,7 +273,7 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
} else {
|
} else {
|
||||||
// No valid link found
|
// No valid link found
|
||||||
context.showInfoSnackBar(
|
context.showInfoSnackBar(
|
||||||
'Unsupported format',
|
l10n.recentlyReadUnsupportedFormat,
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -280,6 +285,7 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
LongPressStartDetails details,
|
LongPressStartDetails details,
|
||||||
dynamic publication,
|
dynamic publication,
|
||||||
) async {
|
) async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final RenderBox overlay =
|
final RenderBox overlay =
|
||||||
Overlay.of(context).context.findRenderObject() as RenderBox;
|
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||||
final RelativeRect position = RelativeRect.fromRect(
|
final RelativeRect position = RelativeRect.fromRect(
|
||||||
|
|
@ -293,13 +299,13 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
|
|
||||||
if (isDeveloperMode) {
|
if (isDeveloperMode) {
|
||||||
items.add(
|
items.add(
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'debug_raw_data',
|
value: 'debug_raw_data',
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.data_object, size: 20),
|
const Icon(Icons.data_object, size: 20),
|
||||||
SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('View raw data'),
|
Text(l10n.recentlyReadViewRawData),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -315,7 +321,7 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
color: Theme.of(context).colorScheme.error, size: 20),
|
color: Theme.of(context).colorScheme.error, size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Remove from Recently Read',
|
l10n.recentlyReadRemove,
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -337,25 +343,23 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _confirmRemoval(BuildContext context, String title) async {
|
Future<bool> _confirmRemoval(BuildContext context, String title) async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Remove from Recently Read'),
|
title: Text(l10n.recentlyReadRemoveTitle),
|
||||||
content: Text(
|
content: Text(l10n.recentlyReadRemoveConfirmation(title)),
|
||||||
'Remove "$title" from your reading history?\n\n'
|
|
||||||
'This will not delete the book from your library.',
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
child: const Text('Cancel'),
|
child: Text(l10n.commonCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
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;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
// Invalidate providers to refresh UI
|
// Invalidate providers to refresh UI
|
||||||
ref.invalidate(recentlyReadProvider);
|
ref.invalidate(recentlyReadProvider);
|
||||||
ref.invalidate(inProgressCountProvider);
|
ref.invalidate(inProgressCountProvider);
|
||||||
ref.invalidate(completedCountProvider);
|
ref.invalidate(completedCountProvider);
|
||||||
|
|
||||||
context.showInfoSnackBar('Removed from Recently Read');
|
context.showInfoSnackBar(l10n.recentlyReadRemoved);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
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) {
|
void _showRawDataDialog(BuildContext context, dynamic publication) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
const encoder = JsonEncoder.withIndent(' ');
|
const encoder = JsonEncoder.withIndent(' ');
|
||||||
final progressJson = encoder.convert(progress.toJson());
|
final progressJson = encoder.convert(progress.toJson());
|
||||||
final publicationJson = encoder.convert(publication.toJson());
|
final publicationJson = encoder.convert(publication.toJson());
|
||||||
|
|
@ -402,7 +410,7 @@ class RecentlyReadCard extends ConsumerWidget {
|
||||||
builder: (context) => Dialog.fullscreen(
|
builder: (context) => Dialog.fullscreen(
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Raw Data'),
|
title: Text(l10n.recentlyReadRawData),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
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 now = DateTime.now();
|
||||||
final difference = now.difference(date);
|
final difference = now.difference(date);
|
||||||
|
|
||||||
if (difference.inDays == 0) {
|
if (difference.inDays == 0) {
|
||||||
return 'Today';
|
return l10n.timeToday;
|
||||||
} else if (difference.inDays == 1) {
|
} else if (difference.inDays == 1) {
|
||||||
return 'Yesterday';
|
return l10n.timeYesterday;
|
||||||
} else if (difference.inDays < 7) {
|
} else if (difference.inDays < 7) {
|
||||||
return '${difference.inDays}d ago';
|
return l10n.timeDaysAgo(difference.inDays);
|
||||||
} else if (difference.inDays < 30) {
|
} else if (difference.inDays < 30) {
|
||||||
return '${(difference.inDays / 7).floor()}w ago';
|
return l10n.timeWeeksAgo((difference.inDays / 7).floor());
|
||||||
} else if (difference.inDays < 365) {
|
} else if (difference.inDays < 365) {
|
||||||
return '${(difference.inDays / 30).floor()}mo ago';
|
return l10n.timeMonthsAgo((difference.inDays / 30).floor());
|
||||||
} else {
|
} else {
|
||||||
return '${(difference.inDays / 365).floor()}y ago';
|
return l10n.timeYearsAgo((difference.inDays / 365).floor());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/models/opds_server.dart';
|
import 'package:worldhopper/models/opds_server.dart';
|
||||||
|
|
||||||
/// Card widget displaying an OPDS server
|
/// Card widget displaying an OPDS server
|
||||||
|
|
@ -18,6 +19,8 @@ class ServerCard extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
|
|
@ -84,23 +87,24 @@ class ServerCard extends StatelessWidget {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'edit',
|
value: 'edit',
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.edit),
|
const Icon(Icons.edit),
|
||||||
SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('Edit'),
|
Text(l10n.serverCardEdit),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'delete',
|
value: 'delete',
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.delete, color: Colors.red),
|
const Icon(Icons.delete, color: Colors.red),
|
||||||
SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('Delete', style: TextStyle(color: Colors.red)),
|
Text(l10n.serverCardDelete,
|
||||||
|
style: const TextStyle(color: Colors.red)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -127,7 +131,7 @@ class ServerCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'Authenticated',
|
l10n.serverCardAuthenticated,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
|
|
@ -149,7 +153,8 @@ class ServerCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'Last synced: ${_formatDate(server.lastSyncedAt!)}',
|
l10n.serverCardLastSynced(
|
||||||
|
_formatDate(context, server.lastSyncedAt!)),
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.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 now = DateTime.now();
|
||||||
final difference = now.difference(date);
|
final difference = now.difference(date);
|
||||||
|
|
||||||
if (difference.inDays > 7) {
|
if (difference.inDays > 7) {
|
||||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||||
} else if (difference.inDays > 0) {
|
} else if (difference.inDays > 0) {
|
||||||
return '${difference.inDays}d ago';
|
return l10n.timeDaysAgo(difference.inDays);
|
||||||
} else if (difference.inHours > 0) {
|
} else if (difference.inHours > 0) {
|
||||||
return '${difference.inHours}h ago';
|
return l10n.timeHoursAgo(difference.inHours);
|
||||||
} else if (difference.inMinutes > 0) {
|
} else if (difference.inMinutes > 0) {
|
||||||
return '${difference.inMinutes}m ago';
|
return l10n.timeMinutesAgo(difference.inMinutes);
|
||||||
} else {
|
} else {
|
||||||
return 'just now';
|
return l10n.timeJustNow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:worldhopper/l10n/app_localizations.dart';
|
||||||
import 'package:worldhopper/providers/connectivity_provider.dart';
|
import 'package:worldhopper/providers/connectivity_provider.dart';
|
||||||
|
|
||||||
/// Custom AppBar that automatically shows offline indicator
|
/// Custom AppBar that automatically shows offline indicator
|
||||||
|
|
@ -82,7 +83,7 @@ class WorldhopperAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.cloud_off,
|
Icons.cloud_off,
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
semanticLabel: 'Offline',
|
semanticLabel: AppLocalizations.of(context).offlineSemanticLabel,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
...?actions,
|
...?actions,
|
||||||
|
|
|
||||||
13
pubspec.lock
13
pubspec.lock
|
|
@ -430,6 +430,11 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "4.0.0"
|
||||||
|
flutter_localizations:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_riverpod:
|
flutter_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -528,6 +533,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
intl:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.20.2"
|
||||||
io:
|
io:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ environment:
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_localizations:
|
||||||
|
sdk: flutter
|
||||||
|
intl: any
|
||||||
|
|
||||||
# State management
|
# State management
|
||||||
flutter_riverpod: ^2.5.1
|
flutter_riverpod: ^2.5.1
|
||||||
|
|
@ -102,6 +105,7 @@ dev_dependencies:
|
||||||
|
|
||||||
# The following section is specific to Flutter packages.
|
# The following section is specific to Flutter packages.
|
||||||
flutter:
|
flutter:
|
||||||
|
generate: true
|
||||||
|
|
||||||
# The following line ensures that the Material Icons font is
|
# The following line ensures that the Material Icons font is
|
||||||
# included with your application, so that you can use the icons in
|
# included with your application, so that you can use the icons in
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue