diff --git a/.gitignore b/.gitignore
index 9491a2f..13119b1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -360,4 +360,5 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
-FodyWeavers.xsd
\ No newline at end of file
+FodyWeavers.xsd
+.dotnet-cli/
diff --git a/BookReader/AppShell.xaml b/BookReader/AppShell.xaml
index b024cb2..0e48eb3 100644
--- a/BookReader/AppShell.xaml
+++ b/BookReader/AppShell.xaml
@@ -1,16 +1,32 @@
-
+
+ Shell.TitleColor="White"
+ Shell.TabBarBackgroundColor="{StaticResource TabBarColor}"
+ Shell.TabBarForegroundColor="#D9C3B4"
+ Shell.TabBarTitleColor="#D9C3B4"
+ Shell.TabBarUnselectedColor="#8B7264">
-
+
+
+
+
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
diff --git a/BookReader/AppShell.xaml.cs b/BookReader/AppShell.xaml.cs
index f1cc725..2f4e81b 100644
--- a/BookReader/AppShell.xaml.cs
+++ b/BookReader/AppShell.xaml.cs
@@ -9,7 +9,5 @@ public partial class AppShell : Shell
InitializeComponent();
Routing.RegisterRoute("reader", typeof(ReaderPage));
- Routing.RegisterRoute("settings", typeof(SettingsPage));
- Routing.RegisterRoute("calibre", typeof(CalibreLibraryPage));
}
-}
\ No newline at end of file
+}
diff --git a/BookReader/BookReader.csproj b/BookReader/BookReader.csproj
index 6aeb8a8..6513151 100644
--- a/BookReader/BookReader.csproj
+++ b/BookReader/BookReader.csproj
@@ -36,14 +36,8 @@
-
-
-
-
-
-
-
-
+
+
MSBuild:Compile
@@ -56,3 +50,4 @@
+
diff --git a/BookReader/Constants.cs b/BookReader/Constants.cs
index 9fe2fa7..c1b1745 100644
--- a/BookReader/Constants.cs
+++ b/BookReader/Constants.cs
@@ -1,8 +1,7 @@
-namespace BookReader;
+namespace BookReader;
public static class Constants
{
- // UI Constants
public static class UI
{
public const int ProgressBarMaxWidth = 120;
@@ -12,29 +11,33 @@ public static class Constants
public const int VerticalItemSpacing = 15;
}
- // Reader Constants
public static class Reader
{
public const int DefaultFontSize = 18;
public const int MinFontSize = 12;
public const int MaxFontSize = 40;
+ public const string DefaultTheme = "sepia";
+ public const double DefaultBrightness = 100;
+ public const double MinBrightness = 70;
+ public const double MaxBrightness = 120;
public const int Base64ChunkSize = 400_000;
+ public const int Base64RawChunkSize = 120_000;
public const int TouchZoneLeftPercent = 30;
public const int TouchZoneRightPercent = 30;
public const int TouchZoneCenterPercent = 40;
public const int SwipeMinDistance = 50;
public const int SwipeMaxDurationMs = 500;
+ public const int ProgressSaveThrottleSeconds = 2;
}
- // Database Constants
public static class Database
{
public const string DatabaseFileName = "bookreader.db3";
public const int RetryCount = 3;
public const int RetryBaseDelayMs = 100;
+ public const int MaxReadingHistoryEntriesPerBook = 200;
}
- // File Constants
public static class Files
{
public const string BooksFolder = "Books";
@@ -44,7 +47,6 @@ public static class Constants
public const string Fb2ZipExtension = ".fb2.zip";
}
- // Network Constants
public static class Network
{
public const int HttpClientTimeoutSeconds = 30;
@@ -52,7 +54,6 @@ public static class Constants
public const int CalibrePageSize = 20;
}
- // Storage Keys
public static class StorageKeys
{
public const string CalibreUrl = "CalibreUrl";
diff --git a/BookReader/Platforms/Android/AndroidManifest.xml b/BookReader/Platforms/Android/AndroidManifest.xml
index 7e30c70..e4d745c 100644
--- a/BookReader/Platforms/Android/AndroidManifest.xml
+++ b/BookReader/Platforms/Android/AndroidManifest.xml
@@ -4,6 +4,7 @@
android:allowBackup="true"
android:supportsRtl="true"
android:theme="@style/Maui.Main"
+ android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
@@ -21,4 +22,4 @@
-
\ No newline at end of file
+
diff --git a/BookReader/Resources/Images/cloud_tab.svg b/BookReader/Resources/Images/cloud_tab.svg
new file mode 100644
index 0000000..a199465
--- /dev/null
+++ b/BookReader/Resources/Images/cloud_tab.svg
@@ -0,0 +1,6 @@
+
diff --git a/BookReader/Resources/Images/library_tab.svg b/BookReader/Resources/Images/library_tab.svg
new file mode 100644
index 0000000..96fe023
--- /dev/null
+++ b/BookReader/Resources/Images/library_tab.svg
@@ -0,0 +1,6 @@
+
diff --git a/BookReader/Resources/Images/settings_tab.svg b/BookReader/Resources/Images/settings_tab.svg
new file mode 100644
index 0000000..5eb00a5
--- /dev/null
+++ b/BookReader/Resources/Images/settings_tab.svg
@@ -0,0 +1,5 @@
+
diff --git a/BookReader/Resources/Raw/wwwroot/index.html b/BookReader/Resources/Raw/wwwroot/index.html
index ee44ead..428baae 100644
--- a/BookReader/Resources/Raw/wwwroot/index.html
+++ b/BookReader/Resources/Raw/wwwroot/index.html
@@ -1,35 +1,35 @@
-
+
Book Reader
@@ -116,7 +116,7 @@
Initializing...
- ⚠️
+ вљ пёЏ
@@ -126,20 +126,14 @@
- ```
-
- ---
-
- ### JavaScript Логика
-
- ```javascript
+
+
+
diff --git a/BookReader/Resources/Styles/AppStyles.xaml b/BookReader/Resources/Styles/AppStyles.xaml
index 7105e8f..b663ac4 100644
--- a/BookReader/Resources/Styles/AppStyles.xaml
+++ b/BookReader/Resources/Styles/AppStyles.xaml
@@ -4,33 +4,141 @@
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:converters="clr-namespace:BookReader.Converters">
-
-
-
- #5D4037
- #3E2723
- #8D6E63
- #6D4C41
- #4CAF50
- #EFEBE9
- #A1887F
+ #F5E8D6
+ #EBC9AE
+ #FFF8F0
+ #F7E8D9
+ #EED7C0
+ #27160E
+ #6E5648
+ #DEC2AA
+ #A65436
+ #6F311D
+ #DFA98D
+ #2F7D5A
+ #C47A3E
+ #B64932
+ #2B1B15
+
+
+
+
+
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/BookReader/Services/CalibreWebService.cs b/BookReader/Services/CalibreWebService.cs
index 5c210eb..c169d4e 100644
--- a/BookReader/Services/CalibreWebService.cs
+++ b/BookReader/Services/CalibreWebService.cs
@@ -10,8 +10,6 @@ public class CalibreWebService : ICalibreWebService
private readonly HttpClient _httpClient;
private readonly ICoverCacheService _coverCacheService;
private string _baseUrl = string.Empty;
- private string _username = string.Empty;
- private string _password = string.Empty;
public CalibreWebService(HttpClient httpClient, ICoverCacheService coverCacheService)
{
@@ -22,16 +20,18 @@ public class CalibreWebService : ICalibreWebService
public void Configure(string url, string username, string password)
{
- _baseUrl = url.TrimEnd('/');
- _username = username;
- _password = password;
+ _baseUrl = url.Trim().TrimEnd('/');
- if (!string.IsNullOrEmpty(_username))
+ if (!string.IsNullOrWhiteSpace(username))
{
- var authBytes = Encoding.ASCII.GetBytes($"{_username}:{_password}");
+ var authBytes = Encoding.ASCII.GetBytes($"{username}:{password}");
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", Convert.ToBase64String(authBytes));
}
+ else
+ {
+ _httpClient.DefaultRequestHeaders.Authorization = null;
+ }
}
public async Task TestConnectionAsync(string url, string username, string password)
@@ -48,23 +48,26 @@ public class CalibreWebService : ICalibreWebService
}
}
- public async Task> GetBooksAsync(string? searchQuery = null, int page = 0, int pageSize = Constants.Network.CalibrePageSize)
+ public async Task>> GetBooksAsync(string? searchQuery = null, int page = 0, int pageSize = Constants.Network.CalibrePageSize)
{
+ if (string.IsNullOrWhiteSpace(_baseUrl))
+ {
+ return Result>.Failure("Сначала укажите адрес сервера Calibre в настройках.");
+ }
+
var books = new List();
try
{
var offset = page * pageSize;
- var query = string.IsNullOrEmpty(searchQuery) ? "" : Uri.EscapeDataString(searchQuery);
+ var query = string.IsNullOrWhiteSpace(searchQuery) ? string.Empty : Uri.EscapeDataString(searchQuery.Trim());
var url = $"{_baseUrl}/ajax/search?query={query}&num={pageSize}&offset={offset}&sort=timestamp&sort_order=desc";
var response = await _httpClient.GetStringAsync(url);
var json = JObject.Parse(response);
-
var bookIds = json["book_ids"]?.ToObject>() ?? new List();
- // Параллельная загрузка данных книг с ограничением
- var semaphore = new SemaphoreSlim(4); // Максимум 4 параллельных запроса
+ var semaphore = new SemaphoreSlim(4);
var tasks = bookIds.Select(async bookId =>
{
await semaphore.WaitAsync();
@@ -79,14 +82,22 @@ public class CalibreWebService : ICalibreWebService
});
var results = await Task.WhenAll(tasks);
- books.AddRange(results.Where(b => b != null)!);
+ books.AddRange(results.Where(book => book != null)!);
+
+ return Result>.Success(books);
+ }
+ catch (TaskCanceledException ex)
+ {
+ return Result>.Failure(new TimeoutException("Сервер Calibre отвечает слишком долго.", ex));
+ }
+ catch (HttpRequestException ex)
+ {
+ return Result>.Failure(new HttpRequestException("Не удалось подключиться к серверу Calibre.", ex));
}
catch (Exception ex)
{
- System.Diagnostics.Debug.WriteLine($"Error fetching Calibre books: {ex.Message}");
+ return Result>.Failure(new Exception("Не удалось загрузить каталог Calibre.", ex));
}
-
- return books;
}
private async Task LoadBookDataAsync(int bookId)
@@ -98,25 +109,27 @@ public class CalibreWebService : ICalibreWebService
var bookJson = JObject.Parse(bookResponse);
var formats = bookJson["formats"]?.ToObject>() ?? new List();
- var supportedFormat = formats.FirstOrDefault(f =>
- f.Equals("EPUB", StringComparison.OrdinalIgnoreCase) ||
- f.Equals("FB2", StringComparison.OrdinalIgnoreCase));
+ var supportedFormat = formats.FirstOrDefault(format =>
+ format.Equals("EPUB", StringComparison.OrdinalIgnoreCase) ||
+ format.Equals("FB2", StringComparison.OrdinalIgnoreCase));
- if (supportedFormat == null) return null;
+ if (supportedFormat == null)
+ {
+ return null;
+ }
var authors = bookJson["authors"]?.ToObject>() ?? new List();
var calibreBook = new CalibreBook
{
Id = bookId.ToString(),
- Title = bookJson["title"]?.ToString() ?? "Unknown",
+ Title = bookJson["title"]?.ToString() ?? "Без названия",
Author = string.Join(", ", authors),
Format = supportedFormat.ToLowerInvariant(),
CoverUrl = $"{_baseUrl}/get/cover/{bookId}",
DownloadUrl = $"{_baseUrl}/get/{supportedFormat}/{bookId}"
};
- // Try to load cover from cache first
var cacheKey = $"cover_{bookId}";
calibreBook.CoverImage = await _coverCacheService.GetCoverAsync(cacheKey);
@@ -127,7 +140,9 @@ public class CalibreWebService : ICalibreWebService
calibreBook.CoverImage = await _httpClient.GetByteArrayAsync(calibreBook.CoverUrl);
await _coverCacheService.SetCoverAsync(cacheKey, calibreBook.CoverImage);
}
- catch { }
+ catch
+ {
+ }
}
return calibreBook;
@@ -140,7 +155,7 @@ public class CalibreWebService : ICalibreWebService
public async Task DownloadBookAsync(CalibreBook book, IProgress? progress = null)
{
- var booksDir = Path.Combine(FileSystem.AppDataDirectory, "Books");
+ var booksDir = Path.Combine(FileSystem.AppDataDirectory, Constants.Files.BooksFolder);
Directory.CreateDirectory(booksDir);
var fileName = $"{Guid.NewGuid()}.{book.Format}";
@@ -164,9 +179,11 @@ public class CalibreWebService : ICalibreWebService
bytesRead += read;
if (totalBytes > 0)
+ {
progress?.Report((double)bytesRead / totalBytes);
+ }
}
return filePath;
}
-}
\ No newline at end of file
+}
diff --git a/BookReader/Services/DatabaseService.cs b/BookReader/Services/DatabaseService.cs
index d823c81..73db552 100644
--- a/BookReader/Services/DatabaseService.cs
+++ b/BookReader/Services/DatabaseService.cs
@@ -13,16 +13,16 @@ public class DatabaseService : IDatabaseService
public DatabaseService()
{
- _dbPath = Path.Combine(FileSystem.AppDataDirectory, "bookreader.db3");
-
- // Polly 8.x retry pipeline: 3 попытки с экспоненциальной задержкой
+ _dbPath = Path.Combine(FileSystem.AppDataDirectory, Constants.Database.DatabaseFileName);
+
_retryPipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder().Handle()
.Handle(ex => ex.Message.Contains("database is locked") || ex.Message.Contains("busy")),
- MaxRetryAttempts = 3,
- DelayGenerator = context => new ValueTask(TimeSpan.FromMilliseconds(100 * Math.Pow(2, context.AttemptNumber))),
+ MaxRetryAttempts = Constants.Database.RetryCount,
+ DelayGenerator = context => new ValueTask(
+ TimeSpan.FromMilliseconds(Constants.Database.RetryBaseDelayMs * Math.Pow(2, context.AttemptNumber))),
OnRetry = context =>
{
System.Diagnostics.Debug.WriteLine($"[Database] Retry {context.AttemptNumber + 1} after {context.RetryDelay.TotalMilliseconds}ms: {context.Outcome.Exception?.Message}");
@@ -34,11 +34,14 @@ public class DatabaseService : IDatabaseService
public async Task InitializeAsync(CancellationToken ct = default)
{
- if (_database != null) return;
+ if (_database != null)
+ {
+ return;
+ }
_database = new SQLiteAsyncConnection(_dbPath, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache);
- await _retryPipeline.ExecuteAsync(async (token) =>
+ await _retryPipeline.ExecuteAsync(async _ =>
{
await _database!.CreateTableAsync();
await _database!.CreateTableAsync();
@@ -49,31 +52,35 @@ public class DatabaseService : IDatabaseService
private async Task EnsureInitializedAsync()
{
if (_database == null)
+ {
await InitializeAsync();
+ }
}
- // Books
public async Task> GetAllBooksAsync(CancellationToken ct = default)
{
await EnsureInitializedAsync();
- return await _retryPipeline.ExecuteAsync(async (token) =>
- await _database!.Table().OrderByDescending(b => b.LastRead).ToListAsync(), ct);
+ return await _retryPipeline.ExecuteAsync(async _ =>
+ await _database!.Table().OrderByDescending(book => book.LastRead).ToListAsync(), ct);
}
public async Task GetBookByIdAsync(int id, CancellationToken ct = default)
{
await EnsureInitializedAsync();
- return await _retryPipeline.ExecuteAsync(async (token) =>
- await _database!.Table().Where(b => b.Id == id).FirstOrDefaultAsync(), ct);
+ return await _retryPipeline.ExecuteAsync(async _ =>
+ await _database!.Table().Where(book => book.Id == id).FirstOrDefaultAsync(), ct);
}
public async Task SaveBookAsync(Book book, CancellationToken ct = default)
{
await EnsureInitializedAsync();
- return await _retryPipeline.ExecuteAsync(async (token) =>
+ return await _retryPipeline.ExecuteAsync(async _ =>
{
if (book.Id != 0)
+ {
return await _database!.UpdateAsync(book);
+ }
+
return await _database!.InsertAsync(book);
}, ct);
}
@@ -81,35 +88,36 @@ public class DatabaseService : IDatabaseService
public async Task UpdateBookAsync(Book book, CancellationToken ct = default)
{
await EnsureInitializedAsync();
- return await _retryPipeline.ExecuteAsync(async (token) =>
- await _database!.UpdateAsync(book), ct);
+ return await _retryPipeline.ExecuteAsync(async _ => await _database!.UpdateAsync(book), ct);
}
public async Task DeleteBookAsync(Book book, CancellationToken ct = default)
{
await EnsureInitializedAsync();
- // Delete associated file
if (File.Exists(book.FilePath))
{
- try { File.Delete(book.FilePath); } catch { }
+ try
+ {
+ File.Delete(book.FilePath);
+ }
+ catch
+ {
+ }
}
- // Delete progress records
- await _retryPipeline.ExecuteAsync(async (token) =>
- await _database!.Table().DeleteAsync(p => p.BookId == book.Id), ct);
+ await _retryPipeline.ExecuteAsync(async _ =>
+ await _database!.Table().DeleteAsync(progress => progress.BookId == book.Id), ct);
- return await _retryPipeline.ExecuteAsync(async (token) =>
- await _database!.DeleteAsync(book), ct);
+ return await _retryPipeline.ExecuteAsync(async _ => await _database!.DeleteAsync(book), ct);
}
- // Settings
public async Task GetSettingAsync(string key, CancellationToken ct = default)
{
await EnsureInitializedAsync();
- return await _retryPipeline.ExecuteAsync(async (token) =>
+ return await _retryPipeline.ExecuteAsync(async _ =>
{
- var setting = await _database!.Table().Where(s => s.Key == key).FirstOrDefaultAsync();
+ var setting = await _database!.Table().Where(item => item.Key == key).FirstOrDefaultAsync();
return setting?.Value;
}, ct);
}
@@ -117,9 +125,9 @@ public class DatabaseService : IDatabaseService
public async Task SetSettingAsync(string key, string value, CancellationToken ct = default)
{
await EnsureInitializedAsync();
- await _retryPipeline.ExecuteAsync(async (token) =>
+ await _retryPipeline.ExecuteAsync(async _ =>
{
- var existing = await _database!.Table().Where(s => s.Key == key).FirstOrDefaultAsync();
+ var existing = await _database!.Table().Where(item => item.Key == key).FirstOrDefaultAsync();
if (existing != null)
{
existing.Value = value;
@@ -135,31 +143,42 @@ public class DatabaseService : IDatabaseService
public async Task> GetAllSettingsAsync(CancellationToken ct = default)
{
await EnsureInitializedAsync();
- return await _retryPipeline.ExecuteAsync(async (token) =>
+ return await _retryPipeline.ExecuteAsync(async _ =>
{
var settings = await _database!.Table().ToListAsync();
- return settings.ToDictionary(s => s.Key, s => s.Value);
+ return settings.ToDictionary(setting => setting.Key, setting => setting.Value);
}, ct);
}
- // Reading Progress
public async Task SaveProgressAsync(ReadingProgress progress, CancellationToken ct = default)
{
await EnsureInitializedAsync();
- await _retryPipeline.ExecuteAsync(async (token) =>
+ await _retryPipeline.ExecuteAsync(async _ =>
{
progress.Timestamp = DateTime.UtcNow;
await _database!.InsertAsync(progress);
+ await _database.ExecuteAsync(
+ @"DELETE FROM ReadingProgress
+ WHERE BookId = ?
+ AND Id NOT IN (
+ SELECT Id FROM ReadingProgress
+ WHERE BookId = ?
+ ORDER BY Timestamp DESC
+ LIMIT ?
+ )",
+ progress.BookId,
+ progress.BookId,
+ Constants.Database.MaxReadingHistoryEntriesPerBook);
}, ct);
}
public async Task GetLatestProgressAsync(int bookId, CancellationToken ct = default)
{
await EnsureInitializedAsync();
- return await _retryPipeline.ExecuteAsync(async (token) =>
+ return await _retryPipeline.ExecuteAsync(async _ =>
await _database!.Table()
- .Where(p => p.BookId == bookId)
- .OrderByDescending(p => p.Timestamp)
+ .Where(progress => progress.BookId == bookId)
+ .OrderByDescending(progress => progress.Timestamp)
.FirstOrDefaultAsync(), ct);
}
-}
\ No newline at end of file
+}
diff --git a/BookReader/Services/ICalibreWebService.cs b/BookReader/Services/ICalibreWebService.cs
index 13c1397..e2d60a6 100644
--- a/BookReader/Services/ICalibreWebService.cs
+++ b/BookReader/Services/ICalibreWebService.cs
@@ -5,7 +5,7 @@ namespace BookReader.Services;
public interface ICalibreWebService
{
Task TestConnectionAsync(string url, string username, string password);
- Task> GetBooksAsync(string? searchQuery = null, int page = 0, int pageSize = 20);
+ Task>> GetBooksAsync(string? searchQuery = null, int page = 0, int pageSize = 20);
Task DownloadBookAsync(CalibreBook book, IProgress? progress = null);
void Configure(string url, string username, string password);
-}
\ No newline at end of file
+}
diff --git a/BookReader/Services/ISettingsService.cs b/BookReader/Services/ISettingsService.cs
index 520c3f6..5be8aa8 100644
--- a/BookReader/Services/ISettingsService.cs
+++ b/BookReader/Services/ISettingsService.cs
@@ -6,10 +6,11 @@ public interface ISettingsService
Task SetAsync(string key, string value);
Task GetIntAsync(string key, int defaultValue = 0);
Task SetIntAsync(string key, int value);
+ Task GetDoubleAsync(string key, double defaultValue = 0);
+ Task SetDoubleAsync(string key, double value);
Task> GetAllAsync();
-
- // Secure storage for sensitive data
+
Task SetSecurePasswordAsync(string password);
Task GetSecurePasswordAsync();
Task ClearSecurePasswordAsync();
-}
\ No newline at end of file
+}
diff --git a/BookReader/Services/SettingsService.cs b/BookReader/Services/SettingsService.cs
index e0b588d..c9e5d62 100644
--- a/BookReader/Services/SettingsService.cs
+++ b/BookReader/Services/SettingsService.cs
@@ -1,4 +1,5 @@
using BookReader.Models;
+using System.Globalization;
namespace BookReader.Services;
@@ -26,13 +27,32 @@ public class SettingsService : ISettingsService
{
var value = await _databaseService.GetSettingAsync(key);
if (int.TryParse(value, out var result))
+ {
return result;
+ }
+
return defaultValue;
}
public async Task SetIntAsync(string key, int value)
{
- await _databaseService.SetSettingAsync(key, value.ToString());
+ await _databaseService.SetSettingAsync(key, value.ToString(CultureInfo.InvariantCulture));
+ }
+
+ public async Task GetDoubleAsync(string key, double defaultValue = 0)
+ {
+ var value = await _databaseService.GetSettingAsync(key);
+ if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result))
+ {
+ return result;
+ }
+
+ return defaultValue;
+ }
+
+ public async Task SetDoubleAsync(string key, double value)
+ {
+ await _databaseService.SetSettingAsync(key, value.ToString(CultureInfo.InvariantCulture));
}
public async Task> GetAllAsync()
@@ -47,7 +67,7 @@ public class SettingsService : ISettingsService
public async Task GetSecurePasswordAsync()
{
- return await SecureStorage.Default.GetAsync(SecureStorageKeys.CalibrePassword);
+ return await SecureStorage.Default.GetAsync(SecureStorageKeys.CalibrePassword) ?? string.Empty;
}
public async Task ClearSecurePasswordAsync()
@@ -55,4 +75,5 @@ public class SettingsService : ISettingsService
SecureStorage.Default.Remove(SecureStorageKeys.CalibrePassword);
await Task.CompletedTask;
}
-}
\ No newline at end of file
+}
+
diff --git a/BookReader/ViewModels/BookshelfViewModel.cs b/BookReader/ViewModels/BookshelfViewModel.cs
index 3a2f3b7..fb0b534 100644
--- a/BookReader/ViewModels/BookshelfViewModel.cs
+++ b/BookReader/ViewModels/BookshelfViewModel.cs
@@ -10,9 +10,7 @@ public partial class BookshelfViewModel : BaseViewModel
{
private readonly IDatabaseService _databaseService;
private readonly IBookParserService _bookParserService;
- private readonly ISettingsService _settingsService;
private readonly INavigationService _navigationService;
- private readonly ICachedImageLoadingService _imageLoadingService;
public ObservableCollection Books { get; } = new();
@@ -22,29 +20,40 @@ public partial class BookshelfViewModel : BaseViewModel
[ObservableProperty]
private string _searchText = string.Empty;
+ [ObservableProperty]
+ private int _booksInProgress;
+
+ [ObservableProperty]
+ private int _completedBooks;
+
+ [ObservableProperty]
+ private Book? _continueReadingBook;
+
+ public bool HasContinueReading => ContinueReadingBook != null;
+ public string LibrarySummary => $"{Books.Count} книг в библиотеке";
+
partial void OnSearchTextChanged(string value)
{
- // Автоматически выполняем поиск при изменении текста
if (string.IsNullOrWhiteSpace(value))
{
- // Если поле пустое - загружаем все книги
LoadBooksCommand.Execute(null);
}
}
+ partial void OnContinueReadingBookChanged(Book? value)
+ {
+ OnPropertyChanged(nameof(HasContinueReading));
+ }
+
public BookshelfViewModel(
IDatabaseService databaseService,
IBookParserService bookParserService,
- ISettingsService settingsService,
- INavigationService navigationService,
- ICachedImageLoadingService imageLoadingService)
+ INavigationService navigationService)
{
_databaseService = databaseService;
_bookParserService = bookParserService;
- _settingsService = settingsService;
_navigationService = navigationService;
- _imageLoadingService = imageLoadingService;
- Title = "My Library";
+ Title = "Библиотека";
}
[RelayCommand]
@@ -56,14 +65,13 @@ public partial class BookshelfViewModel : BaseViewModel
{
var books = await _databaseService.GetAllBooksAsync();
- // Применяем фильтр поиска если есть
if (!string.IsNullOrWhiteSpace(SearchText))
{
- var searchLower = SearchText.ToLowerInvariant();
- books = books.Where(b =>
- b.Title.ToLowerInvariant().Contains(searchLower) ||
- b.Author.ToLowerInvariant().Contains(searchLower)
- ).ToList();
+ var searchLower = SearchText.Trim().ToLowerInvariant();
+ books = books.Where(book =>
+ book.Title.ToLowerInvariant().Contains(searchLower) ||
+ book.Author.ToLowerInvariant().Contains(searchLower))
+ .ToList();
}
Books.Clear();
@@ -71,11 +79,12 @@ public partial class BookshelfViewModel : BaseViewModel
{
Books.Add(book);
}
- IsEmpty = Books.Count == 0;
+
+ RefreshLibraryState();
}
catch (Exception ex)
{
- System.Diagnostics.Debug.WriteLine($"Error loading books: {ex.Message}");
+ await _navigationService.DisplayAlertAsync("Ошибка", $"Не удалось загрузить библиотеку: {ex.Message}", "OK");
}
finally
{
@@ -86,15 +95,8 @@ public partial class BookshelfViewModel : BaseViewModel
[RelayCommand]
public async Task SearchAsync(object? parameter)
{
- // Если параметр пустой или null, используем текущий SearchText
- var searchText = parameter?.ToString() ?? SearchText;
-
- if (string.IsNullOrWhiteSpace(searchText))
- {
- // Очищаем поиск и загружаем все книги
- SearchText = string.Empty;
- }
-
+ var requestedText = parameter?.ToString() ?? SearchText;
+ SearchText = requestedText ?? string.Empty;
await LoadBooksAsync();
}
@@ -110,42 +112,47 @@ public partial class BookshelfViewModel : BaseViewModel
var result = await FilePicker.Default.PickAsync(new PickOptions
{
- PickerTitle = "Select a book",
+ PickerTitle = "Выберите книгу",
FileTypes = customFileTypes
});
- if (result == null) return;
+ if (result == null)
+ {
+ return;
+ }
var extension = Path.GetExtension(result.FileName).ToLowerInvariant();
- if (extension != ".epub" && extension != ".fb2")
+ if (extension != Constants.Files.EpubExtension && extension != Constants.Files.Fb2Extension)
{
- await _navigationService.DisplayAlertAsync("Error", "Only EPUB and FB2 formats are supported.", "OK");
+ await _navigationService.DisplayAlertAsync("Формат не поддерживается", "Сейчас можно добавить только EPUB и FB2.", "OK");
return;
}
IsBusy = true;
- StatusMessage = "Adding book...";
+ StatusMessage = "Добавляю книгу...";
- // Copy to temp if needed and parse
- string filePath;
- using var stream = await result.OpenReadAsync();
+ using var sourceStream = await result.OpenReadAsync();
var tempPath = Path.Combine(FileSystem.CacheDirectory, result.FileName);
- using (var fileStream = File.Create(tempPath))
+ using (var tempFileStream = File.Create(tempPath))
{
- await stream.CopyToAsync(fileStream);
+ await sourceStream.CopyToAsync(tempFileStream);
}
- filePath = tempPath;
- var book = await _bookParserService.ParseAndStoreBookAsync(filePath, result.FileName);
+ var book = await _bookParserService.ParseAndStoreBookAsync(tempPath, result.FileName);
Books.Insert(0, book);
- IsEmpty = false;
+ RefreshLibraryState();
- // Clean temp
- try { File.Delete(tempPath); } catch { }
+ try
+ {
+ File.Delete(tempPath);
+ }
+ catch
+ {
+ }
}
catch (Exception ex)
{
- await _navigationService.DisplayAlertAsync("Error", $"Failed to add book: {ex.Message}", "OK");
+ await _navigationService.DisplayAlertAsync("Ошибка", $"Не удалось добавить книгу: {ex.Message}", "OK");
}
finally
{
@@ -157,29 +164,41 @@ public partial class BookshelfViewModel : BaseViewModel
[RelayCommand]
public async Task DeleteBookAsync(Book book)
{
- if (book == null) return;
+ if (book == null)
+ {
+ return;
+ }
- var confirm = await _navigationService.DisplayAlertAsync("Delete Book",
- $"Are you sure you want to delete \"{book.Title}\"?", "Delete", "Cancel");
+ var confirmed = await _navigationService.DisplayAlertAsync(
+ "Удалить книгу",
+ $"Удалить \"{book.Title}\" из библиотеки?",
+ "Удалить",
+ "Отмена");
- if (!confirm) return;
+ if (!confirmed)
+ {
+ return;
+ }
try
{
await _databaseService.DeleteBookAsync(book);
Books.Remove(book);
- IsEmpty = Books.Count == 0;
+ RefreshLibraryState();
}
catch (Exception ex)
{
- await _navigationService.DisplayAlertAsync("Error", $"Failed to delete book: {ex.Message}", "OK");
+ await _navigationService.DisplayAlertAsync("Ошибка", $"Не удалось удалить книгу: {ex.Message}", "OK");
}
}
[RelayCommand]
public async Task OpenBookAsync(Book book)
{
- if (book == null) return;
+ if (book == null)
+ {
+ return;
+ }
var navigationParameter = new Dictionary
{
@@ -190,14 +209,20 @@ public partial class BookshelfViewModel : BaseViewModel
}
[RelayCommand]
- public async Task OpenSettingsAsync()
- {
- await _navigationService.GoToAsync("settings");
- }
+ public Task OpenSettingsAsync() => _navigationService.GoToAsync("//settings");
[RelayCommand]
- public async Task OpenCalibreLibraryAsync()
+ public Task OpenCalibreLibraryAsync() => _navigationService.GoToAsync("//calibre");
+
+ private void RefreshLibraryState()
{
- await _navigationService.GoToAsync("calibre");
+ IsEmpty = Books.Count == 0;
+ BooksInProgress = Books.Count(book => book.ReadingProgress > 0 && book.ReadingProgress < 1);
+ CompletedBooks = Books.Count(book => book.ReadingProgress >= 1);
+ ContinueReadingBook = Books
+ .OrderByDescending(book => book.LastRead)
+ .FirstOrDefault(book => book.ReadingProgress > 0 && book.ReadingProgress < 1);
+
+ OnPropertyChanged(nameof(LibrarySummary));
}
-}
\ No newline at end of file
+}
diff --git a/BookReader/ViewModels/CalibreLibraryViewModel.cs b/BookReader/ViewModels/CalibreLibraryViewModel.cs
index 7b1a736..79557fd 100644
--- a/BookReader/ViewModels/CalibreLibraryViewModel.cs
+++ b/BookReader/ViewModels/CalibreLibraryViewModel.cs
@@ -13,7 +13,6 @@ public partial class CalibreLibraryViewModel : BaseViewModel
private readonly IDatabaseService _databaseService;
private readonly ISettingsService _settingsService;
private readonly INavigationService _navigationService;
- private readonly ICachedImageLoadingService _imageLoadingService;
public ObservableCollection Books { get; } = new();
@@ -34,21 +33,27 @@ public partial class CalibreLibraryViewModel : BaseViewModel
private int _currentPage;
+ public bool HasConnectionError => !string.IsNullOrWhiteSpace(ConnectionErrorMessage);
+ public bool HasBooks => Books.Count > 0;
+
+ partial void OnConnectionErrorMessageChanged(string value)
+ {
+ OnPropertyChanged(nameof(HasConnectionError));
+ }
+
public CalibreLibraryViewModel(
ICalibreWebService calibreWebService,
IBookParserService bookParserService,
IDatabaseService databaseService,
ISettingsService settingsService,
- INavigationService navigationService,
- ICachedImageLoadingService imageLoadingService)
+ INavigationService navigationService)
{
_calibreWebService = calibreWebService;
_bookParserService = bookParserService;
_databaseService = databaseService;
_settingsService = settingsService;
_navigationService = navigationService;
- _imageLoadingService = imageLoadingService;
- Title = "Calibre Library";
+ Title = "Calibre";
}
[RelayCommand]
@@ -59,35 +64,42 @@ public partial class CalibreLibraryViewModel : BaseViewModel
var password = await _settingsService.GetSecurePasswordAsync();
IsConfigured = !string.IsNullOrWhiteSpace(url);
+ ConnectionErrorMessage = string.Empty;
if (IsConfigured)
{
_calibreWebService.Configure(url, username, password);
await LoadBooksAsync();
}
+ else
+ {
+ Books.Clear();
+ }
}
[RelayCommand]
public async Task LoadBooksAsync()
{
- if (IsBusy || !IsConfigured) return;
+ if (IsBusy || !IsConfigured)
+ {
+ return;
+ }
+
IsBusy = true;
_currentPage = 0;
ConnectionErrorMessage = string.Empty;
try
{
- var books = await _calibreWebService.GetBooksAsync(SearchQuery, _currentPage);
- Books.Clear();
- foreach (var book in books)
+ var result = await _calibreWebService.GetBooksAsync(SearchQuery, _currentPage);
+ if (!result.IsSuccess)
{
- Books.Add(book);
+ Books.Clear();
+ ConnectionErrorMessage = result.ErrorMessage ?? "Не удалось загрузить каталог Calibre.";
+ return;
}
- }
- catch (Exception ex)
- {
- ConnectionErrorMessage = "No connection to Calibre server";
- System.Diagnostics.Debug.WriteLine($"Error loading Calibre library: {ex.Message}");
+
+ ReplaceBooks(result.Value ?? new List());
}
finally
{
@@ -98,7 +110,11 @@ public partial class CalibreLibraryViewModel : BaseViewModel
[RelayCommand]
public async Task RefreshBooksAsync()
{
- if (IsRefreshing || !IsConfigured) return;
+ if (IsRefreshing || !IsConfigured)
+ {
+ return;
+ }
+
IsRefreshing = true;
try
@@ -114,19 +130,32 @@ public partial class CalibreLibraryViewModel : BaseViewModel
[RelayCommand]
public async Task LoadMoreBooksAsync()
{
- if (IsBusy || !IsConfigured) return;
+ if (IsBusy || !IsConfigured || HasConnectionError)
+ {
+ return;
+ }
+
IsBusy = true;
_currentPage++;
try
{
- var books = await _calibreWebService.GetBooksAsync(SearchQuery, _currentPage);
- foreach (var book in books)
+ var result = await _calibreWebService.GetBooksAsync(SearchQuery, _currentPage);
+ if (!result.IsSuccess)
{
- Books.Add(book);
+ DownloadStatus = result.ErrorMessage ?? "Не удалось подгрузить ещё книги.";
+ return;
+ }
+
+ var existingIds = Books.Select(book => book.Id).ToHashSet();
+ foreach (var book in result.Value ?? new List())
+ {
+ if (existingIds.Add(book.Id))
+ {
+ Books.Add(book);
+ }
}
}
- catch { }
finally
{
IsBusy = false;
@@ -142,16 +171,19 @@ public partial class CalibreLibraryViewModel : BaseViewModel
[RelayCommand]
public async Task DownloadBookAsync(CalibreBook calibreBook)
{
- if (calibreBook == null) return;
+ if (calibreBook == null)
+ {
+ return;
+ }
IsBusy = true;
- DownloadStatus = $"Downloading {calibreBook.Title}...";
+ DownloadStatus = $"Загрузка: {calibreBook.Title}";
try
{
- var progress = new Progress(p =>
+ var progress = new Progress(value =>
{
- DownloadStatus = $"Downloading... {p * 100:F0}%";
+ DownloadStatus = $"Загрузка: {value * 100:F0}%";
});
var filePath = await _calibreWebService.DownloadBookAsync(calibreBook, progress);
@@ -161,27 +193,36 @@ public partial class CalibreLibraryViewModel : BaseViewModel
book.CalibreId = calibreBook.Id;
if (calibreBook.CoverImage != null)
+ {
book.CoverImage = calibreBook.CoverImage;
+ }
await _databaseService.UpdateBookAsync(book);
- DownloadStatus = "Download complete!";
- await _navigationService.DisplayAlertAsync("Success", $"\"{calibreBook.Title}\" has been added to your library.", "OK");
+ DownloadStatus = "Книга добавлена в библиотеку";
+ await _navigationService.DisplayAlertAsync("Готово", $"\"{calibreBook.Title}\" добавлена в библиотеку.", "OK");
}
catch (Exception ex)
{
- await _navigationService.DisplayAlertAsync("Error", $"Failed to download: {ex.Message}", "OK");
+ await _navigationService.DisplayAlertAsync("Ошибка", $"Не удалось скачать книгу: {ex.Message}", "OK");
}
finally
{
IsBusy = false;
- DownloadStatus = string.Empty;
}
}
[RelayCommand]
- public async Task OpenSettingsAsync()
+ public Task OpenSettingsAsync() => _navigationService.GoToAsync("//settings");
+
+ private void ReplaceBooks(IEnumerable books)
{
- await _navigationService.GoToAsync("settings");
+ Books.Clear();
+ foreach (var book in books)
+ {
+ Books.Add(book);
+ }
+
+ OnPropertyChanged(nameof(HasBooks));
}
-}
\ No newline at end of file
+}
diff --git a/BookReader/ViewModels/ReaderViewModel.cs b/BookReader/ViewModels/ReaderViewModel.cs
index b196d15..c378ca8 100644
--- a/BookReader/ViewModels/ReaderViewModel.cs
+++ b/BookReader/ViewModels/ReaderViewModel.cs
@@ -1,5 +1,4 @@
-using Android.Graphics.Fonts;
-using BookReader.Models;
+using BookReader.Models;
using BookReader.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -11,7 +10,13 @@ public partial class ReaderViewModel : BaseViewModel
{
private readonly IDatabaseService _databaseService;
private readonly ISettingsService _settingsService;
- private readonly INavigationService _navigationService;
+
+ private double _lastPersistedProgress = -1;
+ private string _lastPersistedCfi = string.Empty;
+ private string _lastPersistedChapter = string.Empty;
+ private int _lastPersistedCurrentPage = -1;
+ private int _lastPersistedTotalPages = -1;
+ private DateTime _lastPersistedAt = DateTime.MinValue;
[ObservableProperty]
private Book? _book;
@@ -28,6 +33,12 @@ public partial class ReaderViewModel : BaseViewModel
[ObservableProperty]
private string _fontFamily = "serif";
+ [ObservableProperty]
+ private string _readerTheme = Constants.Reader.DefaultTheme;
+
+ [ObservableProperty]
+ private double _brightness = Constants.Reader.DefaultBrightness;
+
[ObservableProperty]
private List _chapters = new();
@@ -44,19 +55,18 @@ public partial class ReaderViewModel : BaseViewModel
private int _currentPage = 1;
[ObservableProperty]
- private int _totalPages = 1;
+ private int _totalPages = 100;
- // Это свойство будет обновляться автоматически при изменении любого из полей выше
- public string ChapterProgressText => $"{ChapterCurrentPage} из {ChapterTotalPages}";
+ public string ChapterProgressText => ChapterTotalPages > 1
+ ? $"Глава: {ChapterCurrentPage} из {ChapterTotalPages}"
+ : "Позиция внутри главы появится после перелистывания";
- // Это свойство показывает процент прогресса
- public string ProgressText => $"{CurrentPage}%";
+ public string ProgressText => TotalPages == 100
+ ? $"{CurrentPage}%"
+ : $"Стр. {CurrentPage} из {TotalPages}";
- // Чтобы ChapterProgressText уведомлял интерфейс, добавим частичные методы (особенность Toolkit)
partial void OnChapterCurrentPageChanged(int value) => OnPropertyChanged(nameof(ChapterProgressText));
partial void OnChapterTotalPagesChanged(int value) => OnPropertyChanged(nameof(ChapterProgressText));
-
- // Чтобы ProgressText уведомлял интерфейс, добавим частичные методы (особенность Toolkit)
partial void OnCurrentPageChanged(int value) => OnPropertyChanged(nameof(ProgressText));
partial void OnTotalPagesChanged(int value) => OnPropertyChanged(nameof(ProgressText));
@@ -78,42 +88,40 @@ public partial class ReaderViewModel : BaseViewModel
12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 36, 40
};
- // Events for the view to subscribe to
public event Action? OnJavaScriptRequested;
- public event Action? OnBookReady;
public ReaderViewModel(
IDatabaseService databaseService,
- ISettingsService settingsService,
- INavigationService navigationService)
+ ISettingsService settingsService)
{
_databaseService = databaseService;
_settingsService = settingsService;
- _navigationService = navigationService;
_fontSize = Constants.Reader.DefaultFontSize;
}
public async Task InitializeAsync()
{
- // Валидация: книга должна быть загружена
if (Book == null)
{
System.Diagnostics.Debug.WriteLine("[ReaderViewModel] Book is null, cannot initialize");
return;
}
- // Валидация: файл книги должен существовать
if (!File.Exists(Book.FilePath))
{
System.Diagnostics.Debug.WriteLine($"[ReaderViewModel] Book file not found: {Book.FilePath}");
return;
}
- var savedFontSize = await _settingsService.GetIntAsync(SettingsKeys.DefaultFontSize, Constants.Reader.DefaultFontSize);
- var savedFontFamily = await _settingsService.GetAsync(SettingsKeys.DefaultFontFamily, "serif");
+ FontSize = await _settingsService.GetIntAsync(SettingsKeys.DefaultFontSize, Constants.Reader.DefaultFontSize);
+ FontFamily = await _settingsService.GetAsync(SettingsKeys.DefaultFontFamily, "serif");
+ ReaderTheme = await _settingsService.GetAsync(SettingsKeys.Theme, Constants.Reader.DefaultTheme);
+ Brightness = await _settingsService.GetDoubleAsync(SettingsKeys.Brightness, Constants.Reader.DefaultBrightness);
- FontSize = savedFontSize;
- FontFamily = savedFontFamily;
+ CurrentPage = Book.CurrentPage > 0 ? Book.CurrentPage : 1;
+ TotalPages = Book.TotalPages > 0 ? Book.TotalPages : 100;
+
+ RememberPersistedProgress(Book.ReadingProgress, Book.LastCfi, Book.LastChapter, Book.CurrentPage, Book.TotalPages);
}
[RelayCommand]
@@ -121,7 +129,9 @@ public partial class ReaderViewModel : BaseViewModel
{
IsMenuVisible = !IsMenuVisible;
if (!IsMenuVisible)
+ {
IsChapterListVisible = false;
+ }
}
[RelayCommand]
@@ -149,14 +159,34 @@ public partial class ReaderViewModel : BaseViewModel
public void ChangeFontFamily(string family)
{
FontFamily = family;
- OnJavaScriptRequested?.Invoke($"setFontFamily('{family}')");
+ OnJavaScriptRequested?.Invoke($"setFontFamily('{EscapeJs(family)}')");
_ = _settingsService.SetAsync(SettingsKeys.DefaultFontFamily, family);
}
+ [RelayCommand]
+ public void ChangeReaderTheme(string theme)
+ {
+ ReaderTheme = theme;
+ OnJavaScriptRequested?.Invoke($"setReaderTheme('{EscapeJs(theme)}')");
+ _ = _settingsService.SetAsync(SettingsKeys.Theme, theme);
+ }
+
+ [RelayCommand]
+ public void ChangeBrightness(double brightness)
+ {
+ Brightness = Math.Clamp(brightness, Constants.Reader.MinBrightness, Constants.Reader.MaxBrightness);
+ OnJavaScriptRequested?.Invoke($"setBrightness({Brightness.ToString(System.Globalization.CultureInfo.InvariantCulture)})");
+ _ = _settingsService.SetDoubleAsync(SettingsKeys.Brightness, Brightness);
+ }
+
[RelayCommand]
public void GoToChapter(string chapter)
{
- if (string.IsNullOrEmpty(chapter)) return;
+ if (string.IsNullOrEmpty(chapter))
+ {
+ return;
+ }
+
OnJavaScriptRequested?.Invoke($"goToChapter('{EscapeJs(chapter)}')");
IsChapterListVisible = false;
IsMenuVisible = false;
@@ -169,11 +199,12 @@ public partial class ReaderViewModel : BaseViewModel
System.Diagnostics.Debug.WriteLine("[ReaderViewModel] Cannot save locations: Book is null");
return;
}
+
Book.Locations = locations;
await _databaseService.UpdateBookAsync(Book);
}
- public async Task SaveProgressAsync(double progress, string? cfi, string? chapter, int currentPage, int totalPages)
+ public async Task SaveProgressAsync(double progress, string? cfi, string? chapter, int currentPage, int totalPages, bool force = false)
{
if (Book == null)
{
@@ -181,8 +212,13 @@ public partial class ReaderViewModel : BaseViewModel
return;
}
- // Важно: если CFI пустой, не перезаписываем старый прогресс (защита от багов JS)
- if (string.IsNullOrEmpty(cfi) && progress <= 0) return;
+ if (string.IsNullOrEmpty(cfi) && progress <= 0)
+ {
+ return;
+ }
+
+ CurrentPage = currentPage > 0 ? currentPage : CurrentPage;
+ TotalPages = totalPages > 0 ? totalPages : TotalPages;
Book.ReadingProgress = progress;
Book.LastCfi = cfi;
@@ -191,6 +227,23 @@ public partial class ReaderViewModel : BaseViewModel
Book.TotalPages = totalPages;
Book.LastRead = DateTime.UtcNow;
+ var hasMeaningfulChange = HasMeaningfulProgressChange(progress, cfi, chapter, currentPage, totalPages);
+ if (!force)
+ {
+ var throttle = TimeSpan.FromSeconds(Constants.Reader.ProgressSaveThrottleSeconds);
+ var shouldPersistNow = hasMeaningfulChange &&
+ (DateTime.UtcNow - _lastPersistedAt >= throttle || Math.Abs(progress - _lastPersistedProgress) >= 0.02 || currentPage != _lastPersistedCurrentPage);
+
+ if (!shouldPersistNow)
+ {
+ return;
+ }
+ }
+ else if (!hasMeaningfulChange)
+ {
+ return;
+ }
+
await _databaseService.UpdateBookAsync(Book);
await _databaseService.SaveProgressAsync(new ReadingProgress
@@ -201,30 +254,35 @@ public partial class ReaderViewModel : BaseViewModel
CurrentPage = currentPage,
ChapterTitle = chapter
});
+
+ RememberPersistedProgress(progress, cfi, chapter, currentPage, totalPages);
}
- public string GetBookFilePath()
+ public string? GetLastCfi() => Book?.LastCfi;
+
+ public string? GetLocations() => Book?.Locations;
+
+ private bool HasMeaningfulProgressChange(double progress, string? cfi, string? chapter, int currentPage, int totalPages)
{
- return Book?.FilePath ?? string.Empty;
+ return Math.Abs(progress - _lastPersistedProgress) >= 0.005 ||
+ string.Equals(cfi ?? string.Empty, _lastPersistedCfi, StringComparison.Ordinal) == false ||
+ string.Equals(chapter ?? string.Empty, _lastPersistedChapter, StringComparison.Ordinal) == false ||
+ currentPage != _lastPersistedCurrentPage ||
+ totalPages != _lastPersistedTotalPages;
}
- public string GetBookFormat()
+ private void RememberPersistedProgress(double progress, string? cfi, string? chapter, int currentPage, int totalPages)
{
- return Book?.Format ?? "epub";
- }
-
- public string? GetLastCfi()
- {
- return Book?.LastCfi;
- }
-
- public string? GetLocations()
- {
- return Book?.Locations;
+ _lastPersistedProgress = progress;
+ _lastPersistedCfi = cfi ?? string.Empty;
+ _lastPersistedChapter = chapter ?? string.Empty;
+ _lastPersistedCurrentPage = currentPage;
+ _lastPersistedTotalPages = totalPages;
+ _lastPersistedAt = DateTime.UtcNow;
}
private static string EscapeJs(string value)
{
return value.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\n", "\\n").Replace("\r", "\\r");
}
-}
\ No newline at end of file
+}
diff --git a/BookReader/ViewModels/SettingsViewModel.cs b/BookReader/ViewModels/SettingsViewModel.cs
index 63bf0bb..18886db 100644
--- a/BookReader/ViewModels/SettingsViewModel.cs
+++ b/BookReader/ViewModels/SettingsViewModel.cs
@@ -2,11 +2,17 @@
using BookReader.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using Microsoft.Maui.Graphics;
namespace BookReader.ViewModels;
public partial class SettingsViewModel : BaseViewModel
{
+ private static readonly Color SuccessTone = Color.FromArgb("#2F7D5A");
+ private static readonly Color WarningTone = Color.FromArgb("#C47A3E");
+ private static readonly Color DangerTone = Color.FromArgb("#B64932");
+ private static readonly Color NeutralTone = Color.FromArgb("#6E5648");
+
private readonly ISettingsService _settingsService;
private readonly ICalibreWebService _calibreWebService;
private readonly INavigationService _navigationService;
@@ -26,9 +32,24 @@ public partial class SettingsViewModel : BaseViewModel
[ObservableProperty]
private string _defaultFontFamily = "serif";
+ [ObservableProperty]
+ private string _defaultReaderTheme = "Тёплая";
+
+ [ObservableProperty]
+ private double _defaultBrightness = Constants.Reader.DefaultBrightness;
+
[ObservableProperty]
private string _connectionStatus = string.Empty;
+ [ObservableProperty]
+ private Color _connectionStatusColor = NeutralTone;
+
+ [ObservableProperty]
+ private string _connectionSecurityHint = string.Empty;
+
+ [ObservableProperty]
+ private Color _connectionSecurityColor = NeutralTone;
+
[ObservableProperty]
private bool _isConnectionTesting;
@@ -43,6 +64,18 @@ public partial class SettingsViewModel : BaseViewModel
12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 36, 40
};
+ public List AvailableReaderThemes { get; } = new()
+ {
+ "Тёплая",
+ "Светлая",
+ "Тёмная"
+ };
+
+ partial void OnCalibreUrlChanged(string value)
+ {
+ UpdateConnectionSecurityHint();
+ }
+
public SettingsViewModel(
ISettingsService settingsService,
ICalibreWebService calibreWebService,
@@ -51,7 +84,7 @@ public partial class SettingsViewModel : BaseViewModel
_settingsService = settingsService;
_calibreWebService = calibreWebService;
_navigationService = navigationService;
- Title = "Settings";
+ Title = "Настройки";
}
[RelayCommand]
@@ -62,49 +95,144 @@ public partial class SettingsViewModel : BaseViewModel
CalibrePassword = await _settingsService.GetSecurePasswordAsync();
DefaultFontSize = await _settingsService.GetIntAsync(SettingsKeys.DefaultFontSize, Constants.Reader.DefaultFontSize);
DefaultFontFamily = await _settingsService.GetAsync(SettingsKeys.DefaultFontFamily, "serif");
+ DefaultReaderTheme = ToDisplayTheme(await _settingsService.GetAsync(SettingsKeys.Theme, Constants.Reader.DefaultTheme));
+ DefaultBrightness = await _settingsService.GetDoubleAsync(SettingsKeys.Brightness, Constants.Reader.DefaultBrightness);
+
+ UpdateConnectionSecurityHint();
}
[RelayCommand]
public async Task SaveSettingsAsync()
{
- await _settingsService.SetAsync(SettingsKeys.CalibreUrl, CalibreUrl);
- await _settingsService.SetAsync(SettingsKeys.CalibreUsername, CalibreUsername);
- await _settingsService.SetSecurePasswordAsync(CalibrePassword);
- await _settingsService.SetIntAsync(SettingsKeys.DefaultFontSize, DefaultFontSize);
- await _settingsService.SetAsync(SettingsKeys.DefaultFontFamily, DefaultFontFamily);
+ await PersistSettingsAsync(showNotification: true);
+ }
- if (!string.IsNullOrEmpty(CalibreUrl))
- {
- _calibreWebService.Configure(CalibreUrl, CalibreUsername, CalibrePassword);
- }
-
- await _navigationService.DisplayAlertAsync("Settings", "Settings saved successfully.", "OK");
+ public async Task SaveSilentlyAsync()
+ {
+ await PersistSettingsAsync(showNotification: false);
}
[RelayCommand]
public async Task TestConnectionAsync()
{
- if (string.IsNullOrWhiteSpace(CalibreUrl))
+ if (!TryValidateCalibreUrl(out var validationMessage))
{
- ConnectionStatus = "Please enter a URL";
+ ConnectionStatus = validationMessage;
+ ConnectionStatusColor = DangerTone;
return;
}
IsConnectionTesting = true;
- ConnectionStatus = "Testing connection...";
+ ConnectionStatus = "Проверяю соединение...";
+ ConnectionStatusColor = NeutralTone;
try
{
var success = await _calibreWebService.TestConnectionAsync(CalibreUrl, CalibreUsername, CalibrePassword);
- ConnectionStatus = success ? "✅ Connection successful!" : "❌ Connection failed";
+ ConnectionStatus = success ? "Соединение установлено." : "Сервер ответил ошибкой или недоступен.";
+ ConnectionStatusColor = success ? SuccessTone : DangerTone;
}
catch (Exception ex)
{
- ConnectionStatus = $"❌ Error: {ex.Message}";
+ ConnectionStatus = $"Ошибка проверки: {ex.Message}";
+ ConnectionStatusColor = DangerTone;
}
finally
{
IsConnectionTesting = false;
}
}
-}
\ No newline at end of file
+
+ private async Task PersistSettingsAsync(bool showNotification)
+ {
+ await _settingsService.SetAsync(SettingsKeys.CalibreUrl, CalibreUrl);
+ await _settingsService.SetAsync(SettingsKeys.CalibreUsername, CalibreUsername);
+ await _settingsService.SetSecurePasswordAsync(CalibrePassword);
+ await _settingsService.SetIntAsync(SettingsKeys.DefaultFontSize, DefaultFontSize);
+ await _settingsService.SetAsync(SettingsKeys.DefaultFontFamily, DefaultFontFamily);
+ await _settingsService.SetAsync(SettingsKeys.Theme, ToStoredTheme(DefaultReaderTheme));
+ await _settingsService.SetDoubleAsync(SettingsKeys.Brightness, DefaultBrightness);
+
+ if (!string.IsNullOrWhiteSpace(CalibreUrl))
+ {
+ _calibreWebService.Configure(CalibreUrl, CalibreUsername, CalibrePassword);
+ }
+
+ if (showNotification)
+ {
+ await _navigationService.DisplayAlertAsync("Настройки", "Изменения сохранены.", "OK");
+ }
+ }
+
+ private bool TryValidateCalibreUrl(out string message)
+ {
+ if (string.IsNullOrWhiteSpace(CalibreUrl))
+ {
+ message = "Введите адрес сервера Calibre.";
+ return false;
+ }
+
+ if (!Uri.TryCreate(CalibreUrl, UriKind.Absolute, out var uri))
+ {
+ message = "Адрес сервера должен быть полным URL, например https://server.example.";
+ return false;
+ }
+
+ if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
+ {
+ message = "Поддерживаются только адреса с http:// или https://.";
+ return false;
+ }
+
+ message = string.Empty;
+ return true;
+ }
+
+ private void UpdateConnectionSecurityHint()
+ {
+ if (string.IsNullOrWhiteSpace(CalibreUrl))
+ {
+ ConnectionSecurityHint = "Если Calibre доступен из интернета, используйте HTTPS. Для локальной сети HTTP допустим, но менее безопасен.";
+ ConnectionSecurityColor = NeutralTone;
+ return;
+ }
+
+ if (!Uri.TryCreate(CalibreUrl, UriKind.Absolute, out var uri))
+ {
+ ConnectionSecurityHint = "Проверьте адрес сервера. Нужен полный URL с http:// или https://.";
+ ConnectionSecurityColor = DangerTone;
+ return;
+ }
+
+ if (uri.Scheme == Uri.UriSchemeHttps)
+ {
+ ConnectionSecurityHint = "HTTPS включён: логин и пароль передаются по зашифрованному каналу.";
+ ConnectionSecurityColor = SuccessTone;
+ return;
+ }
+
+ if (uri.Scheme == Uri.UriSchemeHttp)
+ {
+ ConnectionSecurityHint = "HTTP подходит для домашней сети, но трафик и пароль не шифруются.";
+ ConnectionSecurityColor = WarningTone;
+ return;
+ }
+
+ ConnectionSecurityHint = "Поддерживаются только схемы http:// и https://.";
+ ConnectionSecurityColor = DangerTone;
+ }
+
+ private static string ToDisplayTheme(string storedTheme) => storedTheme switch
+ {
+ "light" => "Светлая",
+ "dark" => "Тёмная",
+ _ => "Тёплая"
+ };
+
+ private static string ToStoredTheme(string displayTheme) => displayTheme switch
+ {
+ "Светлая" => "light",
+ "Тёмная" => "dark",
+ _ => Constants.Reader.DefaultTheme
+ };
+}
diff --git a/BookReader/Views/BookshelfPage.xaml b/BookReader/Views/BookshelfPage.xaml
index c56d37d..f5bb9a0 100644
--- a/BookReader/Views/BookshelfPage.xaml
+++ b/BookReader/Views/BookshelfPage.xaml
@@ -1,4 +1,4 @@
-
+
+ Title="Библиотека"
+ Background="{StaticResource AppBackgroundBrush}">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Margin="0,0,0,24">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
-
-
-
-
+
-
-
+
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/BookReader/Views/BookshelfPage.xaml.cs b/BookReader/Views/BookshelfPage.xaml.cs
index 8342ebd..780b898 100644
--- a/BookReader/Views/BookshelfPage.xaml.cs
+++ b/BookReader/Views/BookshelfPage.xaml.cs
@@ -1,4 +1,4 @@
-using BookReader.Services;
+using BookReader.Services;
using BookReader.ViewModels;
namespace BookReader.Views;
@@ -6,20 +6,20 @@ namespace BookReader.Views;
public partial class BookshelfPage : ContentPage
{
private readonly BookshelfViewModel _viewModel;
- private readonly INavigationService _navigationService;
- public BookshelfPage(BookshelfViewModel viewModel, INavigationService navigationService, ICachedImageLoadingService imageLoadingService)
+ public BookshelfPage(BookshelfViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
- _navigationService = navigationService;
BindingContext = viewModel;
+ SizeChanged += OnPageSizeChanged;
}
protected override void OnAppearing()
{
base.OnAppearing();
- // Загружаем книги только если коллекция пуста
+ UpdateGridSpan(Width);
+
if (_viewModel.Books.Count == 0)
{
_ = _viewModel.LoadBooksCommand.ExecuteAsync(null);
@@ -29,31 +29,32 @@ public partial class BookshelfPage : ContentPage
protected override async void OnNavigatedTo(NavigatedToEventArgs args)
{
base.OnNavigatedTo(args);
-
- // Если вернулись на главную страницу и книги уже загружены - обновляем прогресс
- // (например, после чтения)
+
if (_viewModel.Books.Count > 0 && !_viewModel.IsBusy)
{
await _viewModel.LoadBooksCommand.ExecuteAsync(null);
}
}
- private async void OnMenuClicked(object? sender, EventArgs e)
+ private void OnPageSizeChanged(object? sender, EventArgs e)
{
- var action = await _navigationService.DisplayActionSheetAsync("Menu", "Cancel",
- "⚙️ Settings", "☁️ Calibre Library", "ℹ️ About");
-
- switch (action)
- {
- case "⚙️ Settings":
- await _viewModel.OpenSettingsCommand.ExecuteAsync(null);
- break;
- case "☁️ Calibre Library":
- await _viewModel.OpenCalibreLibraryCommand.ExecuteAsync(null);
- break;
- case "ℹ️ About":
- await _navigationService.DisplayAlertAsync("About", "BookReader v1.0\nEPUB & FB2 Reader", "OK");
- break;
- }
+ UpdateGridSpan(Width);
}
-}
\ No newline at end of file
+
+ private void UpdateGridSpan(double availableWidth)
+ {
+ if (availableWidth <= 0)
+ {
+ return;
+ }
+
+ var span = availableWidth switch
+ {
+ < 520 => 2,
+ < 920 => 3,
+ _ => 4
+ };
+
+ BooksGridLayout.Span = span;
+ }
+}
diff --git a/BookReader/Views/CalibreLibraryPage.xaml b/BookReader/Views/CalibreLibraryPage.xaml
index cf5bd91..a22438c 100644
--- a/BookReader/Views/CalibreLibraryPage.xaml
+++ b/BookReader/Views/CalibreLibraryPage.xaml
@@ -1,163 +1,169 @@
-
+
+ Title="Calibre"
+ Background="{StaticResource AppBackgroundBrush}">
-
-
-
-
-
-
-
+
-
-
-
+ Padding="20,24,20,14"
+ Spacing="16">
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/BookReader/Views/CalibreLibraryPage.xaml.cs b/BookReader/Views/CalibreLibraryPage.xaml.cs
index 583eacf..d4733d2 100644
--- a/BookReader/Views/CalibreLibraryPage.xaml.cs
+++ b/BookReader/Views/CalibreLibraryPage.xaml.cs
@@ -1,5 +1,4 @@
-using BookReader.Services;
-using BookReader.ViewModels;
+using BookReader.ViewModels;
namespace BookReader.Views;
@@ -7,7 +6,7 @@ public partial class CalibreLibraryPage : ContentPage
{
private readonly CalibreLibraryViewModel _viewModel;
- public CalibreLibraryPage(CalibreLibraryViewModel viewModel, ICachedImageLoadingService imageLoadingService)
+ public CalibreLibraryPage(CalibreLibraryViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
@@ -19,4 +18,4 @@ public partial class CalibreLibraryPage : ContentPage
base.OnAppearing();
await _viewModel.InitializeCommand.ExecuteAsync(null);
}
-}
\ No newline at end of file
+}
diff --git a/BookReader/Views/ReaderPage.xaml b/BookReader/Views/ReaderPage.xaml
index 683eff9..4cc7f8e 100644
--- a/BookReader/Views/ReaderPage.xaml
+++ b/BookReader/Views/ReaderPage.xaml
@@ -1,165 +1,194 @@
-
+
+ NavigationPage.HasNavigationBar="False"
+ BackgroundColor="#1B130F">
-
+ RawMessageReceived="OnRawMessageReceived"
+ DefaultFile="index.html"
+ HorizontalOptions="Fill"
+ VerticalOptions="Fill" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
-
-
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
-
+ TextColor="White"
+ FontSize="12"
+ HorizontalTextAlignment="Center" />
+
-
\ No newline at end of file
+
diff --git a/BookReader/Views/ReaderPage.xaml.cs b/BookReader/Views/ReaderPage.xaml.cs
index c52155e..6efa047 100644
--- a/BookReader/Views/ReaderPage.xaml.cs
+++ b/BookReader/Views/ReaderPage.xaml.cs
@@ -1,6 +1,7 @@
-using BookReader.Services;
+using BookReader.Services;
using BookReader.ViewModels;
using Newtonsoft.Json.Linq;
+using System.Globalization;
namespace BookReader.Views;
@@ -8,9 +9,10 @@ public partial class ReaderPage : ContentPage
{
private readonly ReaderViewModel _viewModel;
private readonly INavigationService _navigationService;
- private bool _isBookLoaded;
private readonly List _chapterData = new();
private bool _isActive;
+ private bool _isBookLoaded;
+ private bool _isSubscribedToJavaScriptRequests;
public ReaderPage(ReaderViewModel viewModel, INavigationService navigationService)
{
@@ -18,19 +20,18 @@ public partial class ReaderPage : ContentPage
_viewModel = viewModel;
_navigationService = navigationService;
BindingContext = viewModel;
- _viewModel.OnJavaScriptRequested += OnJavaScriptRequested;
}
protected override async void OnAppearing()
{
base.OnAppearing();
_isActive = true;
+ EnsureSubscribed();
try
{
await _viewModel.InitializeAsync();
System.Diagnostics.Debug.WriteLine("[Reader] ViewModel initialized");
-
}
catch (Exception ex)
{
@@ -41,7 +42,7 @@ public partial class ReaderPage : ContentPage
protected override async void OnDisappearing()
{
_isActive = false;
- _viewModel.OnJavaScriptRequested -= OnJavaScriptRequested;
+ EnsureUnsubscribed();
base.OnDisappearing();
await SaveCurrentProgress();
}
@@ -50,107 +51,84 @@ public partial class ReaderPage : ContentPage
{
_isActive = false;
base.OnNavigatedFrom(args);
- // Сохраняем немедленно при любом уходе со страницы
await SaveCurrentProgress();
}
- // ========== ЗАГРУЗКА КНИГИ ==========
-
private async Task LoadBookIntoWebView()
{
try
{
var book = _viewModel.Book;
- if (book == null)
- {
- return;
- }
-
- if (_isBookLoaded)
+ if (book == null || _isBookLoaded)
{
return;
}
if (!File.Exists(book.FilePath))
{
- await _navigationService.DisplayAlertAsync("Error", "Book file not found", "OK");
+ await _navigationService.DisplayAlertAsync("Ошибка", "Файл книги не найден.", "OK");
return;
}
-
- // Читаем файл и конвертируем в Base64
- var fileBytes = await File.ReadAllBytesAsync(book.FilePath);
- var base64 = Convert.ToBase64String(fileBytes);
var format = book.Format.ToLowerInvariant();
- var lastCfi = _viewModel.GetLastCfi() ?? "";
- var locations=_viewModel.GetLocations() ?? "";
+ var lastCfi = _viewModel.GetLastCfi() ?? string.Empty;
+ var locations = _viewModel.GetLocations() ?? string.Empty;
- // Отправляем данные чанками чтобы не превысить лимит JS строки
- const int chunkSize = 400_000;
+ await EvalJsAsync("window._bkChunks = [];");
- if (base64.Length > chunkSize)
+ using (var fileStream = File.OpenRead(book.FilePath))
{
- var chunks = SplitString(base64, chunkSize);
+ var buffer = new byte[Constants.Reader.Base64RawChunkSize];
+ int bytesRead;
- await EvalJsAsync("window._bkChunks = [];");
-
- for (int i = 0; i < chunks.Count; i++)
+ while ((bytesRead = await fileStream.ReadAsync(buffer.AsMemory(0, buffer.Length))) > 0)
{
- await EvalJsAsync($"window._bkChunks.push('{chunks[i]}');");
+ var base64Chunk = Convert.ToBase64String(buffer, 0, bytesRead);
+ await EvalJsAsync($"window._bkChunks.push('{base64Chunk}');");
}
+ }
- await EvalJsAsync(
- $"window.loadBookFromBase64(window._bkChunks.join(''), '{format}', '{EscapeJs(lastCfi)}','{EscapeJs(locations)}');"
- );
- await EvalJsAsync("delete window._bkChunks;");
- }
- else
- {
- await EvalJsAsync(
- $"window.loadBookFromBase64('{base64}', '{format}', '{EscapeJs(lastCfi)}','{EscapeJs(locations)}');"
- );
- }
+ await EvalJsAsync(
+ $"window.loadBookFromBase64(window._bkChunks.join(''), '{format}', '{EscapeJs(lastCfi)}', '{EscapeJs(locations)}');");
+ await EvalJsAsync("delete window._bkChunks;");
_isBookLoaded = true;
- System.Diagnostics.Debug.WriteLine("[Reader] Book load command sent");
-
-
- // Применяем настройки шрифта сразу
- await EvalJsAsync($"window.setFontSize({_viewModel.FontSize})");
+ await EvalJsAsync($"window.setFontSize({_viewModel.FontSize.ToString(CultureInfo.InvariantCulture)})");
await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')");
-
- System.Diagnostics.Debug.WriteLine("[Reader] Book fully loaded");
+ await EvalJsAsync($"window.setReaderTheme('{EscapeJs(_viewModel.ReaderTheme)}')");
+ await EvalJsAsync($"window.setBrightness({_viewModel.Brightness.ToString(CultureInfo.InvariantCulture)})");
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Reader] Load error: {ex.Message}\n{ex.StackTrace}");
- await _navigationService.DisplayAlertAsync("Error", $"Failed to load book: {ex.Message}", "OK");
+ await _navigationService.DisplayAlertAsync("Ошибка", $"Не удалось загрузить книгу: {ex.Message}", "OK");
}
}
- // ========== СОХРАНЕНИЕ ПРОГРЕССА ==========
-
private async Task SaveCurrentProgress()
{
- if (!_isBookLoaded) return;
+ if (!_isBookLoaded)
+ {
+ return;
+ }
try
{
var result = await EvalJsWithResultAsync("window.getProgress()");
- if (string.IsNullOrEmpty(result) || result == "null" || result == "{}" || result == "undefined")
+ if (string.IsNullOrEmpty(result) || result is "null" or "{}" or "undefined")
+ {
return;
+ }
result = UnescapeJsResult(result);
-
var data = JObject.Parse(result);
var progress = data["progress"]?.Value() ?? 0;
var cfi = data["cfi"]?.ToString();
var currentPage = data["currentPage"]?.Value() ?? 0;
var totalPages = data["totalPages"]?.Value() ?? 0;
- await _viewModel.SaveProgressAsync(progress, cfi, null, currentPage, totalPages);
- System.Diagnostics.Debug.WriteLine($"[Reader] Saved progress: {progress:P0}");
+ await _viewModel.SaveProgressAsync(progress, cfi, _viewModel.Book?.LastChapter, currentPage, totalPages, force: true);
}
catch (Exception ex)
{
@@ -158,16 +136,16 @@ public partial class ReaderPage : ContentPage
}
}
- // ========== ОБРАБОТКА СООБЩЕНИЙ ОТ JS ==========
-
private async void OnRawMessageReceived(object? sender, HybridWebViewRawMessageReceivedEventArgs e)
{
try
{
var message = e.Message;
- if (string.IsNullOrEmpty(message)) return;
+ if (string.IsNullOrEmpty(message))
+ {
+ return;
+ }
- // ... (оставляем логику логирования и парсинга JSON) ...
var json = JObject.Parse(message);
var action = json["action"]?.ToString();
var data = json["data"] as JObject;
@@ -175,16 +153,11 @@ public partial class ReaderPage : ContentPage
switch (action)
{
case "readerReady":
-
- // Вызываем загрузку книги ТОЛЬКО после того, как JS подтвердил готовность
_ = MainThread.InvokeOnMainThreadAsync(LoadBookIntoWebView);
break;
case "toggleMenu":
- MainThread.BeginInvokeOnMainThread(() =>
- {
- _viewModel.ToggleMenuCommand.Execute(null);
- });
+ MainThread.BeginInvokeOnMainThread(() => _viewModel.ToggleMenuCommand.Execute(null));
break;
case "progressUpdate":
@@ -195,17 +168,17 @@ public partial class ReaderPage : ContentPage
var chapter = data["chapter"]?.ToString();
var currentPage = data["currentPage"]?.Value() ?? 0;
var totalPages = data["totalPages"]?.Value() ?? 0;
-
- // Ловим новые данные по главе
var chapterPage = data["chapterCurrentPage"]?.Value() ?? 1;
var chapterTotal = data["chapterTotalPages"]?.Value() ?? 1;
- // Обновляем ViewModel на главном потоке
- MainThread.BeginInvokeOnMainThread(() => {
+
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
_viewModel.ChapterCurrentPage = chapterPage;
_viewModel.ChapterTotalPages = chapterTotal;
_viewModel.TotalPages = totalPages;
_viewModel.CurrentPage = currentPage;
});
+
await _viewModel.SaveProgressAsync(progress, cfi, chapter, currentPage, totalPages);
}
break;
@@ -213,35 +186,35 @@ public partial class ReaderPage : ContentPage
case "chaptersLoaded":
if (data != null)
{
- var chapters = data["chapters"]?.ToObject>() ?? new();
+ var chapters = data["chapters"]?.ToObject>() ?? new List();
_chapterData.Clear();
_chapterData.AddRange(chapters);
MainThread.BeginInvokeOnMainThread(() =>
{
_viewModel.Chapters = chapters
- .Select(c => c["label"]?.ToString() ?? "")
- .Where(l => !string.IsNullOrWhiteSpace(l))
+ .Select(chapter => chapter["label"]?.ToString() ?? string.Empty)
+ .Where(label => !string.IsNullOrWhiteSpace(label))
.ToList();
});
}
break;
case "bookReady":
- if (data != null)
+ MainThread.BeginInvokeOnMainThread(async () =>
{
- MainThread.BeginInvokeOnMainThread(async () =>
- {
- await EvalJsAsync($"window.setFontSize({_viewModel.FontSize})");
- await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')");
- });
- }
+ await EvalJsAsync($"window.setFontSize({_viewModel.FontSize.ToString(CultureInfo.InvariantCulture)})");
+ await EvalJsAsync($"window.setFontFamily('{EscapeJs(_viewModel.FontFamily)}')");
+ await EvalJsAsync($"window.setReaderTheme('{EscapeJs(_viewModel.ReaderTheme)}')");
+ await EvalJsAsync($"window.setBrightness({_viewModel.Brightness.ToString(CultureInfo.InvariantCulture)})");
+ });
break;
case "saveLocations":
- // Извлекаем строку локаций из данных
- string locations = data["locations"]?.ToString();
- // Сохраняем в базу данных
- await _viewModel.SaveLocationsAsync(locations);
+ if (data != null)
+ {
+ var locations = data["locations"]?.ToString() ?? string.Empty;
+ await _viewModel.SaveLocationsAsync(locations);
+ }
break;
}
}
@@ -251,34 +224,54 @@ public partial class ReaderPage : ContentPage
}
}
- // ========== ОБРАБОТКА ЗАПРОСОВ JS ОТ VIEWMODEL ==========
-
private async void OnJavaScriptRequested(string script)
{
- if (!_isActive) return;
+ if (!_isActive)
+ {
+ return;
+ }
+
await EvalJsAsync(script);
}
- // ========== UI EVENTS ==========
-
private void OnDecreaseFontSize(object? sender, EventArgs e)
{
- var idx = _viewModel.AvailableFontSizes.IndexOf(_viewModel.FontSize);
- if (idx > 0)
+ var index = _viewModel.AvailableFontSizes.IndexOf(_viewModel.FontSize);
+ if (index > 0)
{
- _viewModel.ChangeFontSizeCommand.Execute(_viewModel.AvailableFontSizes[idx - 1]);
+ _viewModel.ChangeFontSizeCommand.Execute(_viewModel.AvailableFontSizes[index - 1]);
}
}
private void OnIncreaseFontSize(object? sender, EventArgs e)
{
- var idx = _viewModel.AvailableFontSizes.IndexOf(_viewModel.FontSize);
- if (idx < _viewModel.AvailableFontSizes.Count - 1)
+ var index = _viewModel.AvailableFontSizes.IndexOf(_viewModel.FontSize);
+ if (index < _viewModel.AvailableFontSizes.Count - 1)
{
- _viewModel.ChangeFontSizeCommand.Execute(_viewModel.AvailableFontSizes[idx + 1]);
+ _viewModel.ChangeFontSizeCommand.Execute(_viewModel.AvailableFontSizes[index + 1]);
}
}
+ private void OnSepiaThemeClicked(object? sender, EventArgs e)
+ {
+ _viewModel.ChangeReaderThemeCommand.Execute("sepia");
+ }
+
+ private void OnLightThemeClicked(object? sender, EventArgs e)
+ {
+ _viewModel.ChangeReaderThemeCommand.Execute("light");
+ }
+
+ private void OnDarkThemeClicked(object? sender, EventArgs e)
+ {
+ _viewModel.ChangeReaderThemeCommand.Execute("dark");
+ }
+
+ private void OnBrightnessChanged(object? sender, ValueChangedEventArgs e)
+ {
+ _viewModel.ChangeBrightnessCommand.Execute(e.NewValue);
+ }
+
private void OnFontFamilyChanged(object? sender, EventArgs e)
{
if (FontFamilyPicker.SelectedItem is string family)
@@ -291,15 +284,14 @@ public partial class ReaderPage : ContentPage
{
if (e.CurrentSelection.FirstOrDefault() is string chapterLabel)
{
- var chapterObj = _chapterData.FirstOrDefault(c => c["label"]?.ToString() == chapterLabel);
- var href = chapterObj?["href"]?.ToString() ?? chapterLabel;
+ var chapterObject = _chapterData.FirstOrDefault(chapter => chapter["label"]?.ToString() == chapterLabel);
+ var href = chapterObject?["href"]?.ToString() ?? chapterLabel;
_viewModel.GoToChapterCommand.Execute(href);
}
}
private void OnMenuPanelTapped(object? sender, TappedEventArgs e)
{
- // Предотвращаем всплытие тапа на оверлей
}
private async void OnBackToLibrary(object? sender, EventArgs e)
@@ -308,11 +300,28 @@ public partial class ReaderPage : ContentPage
await _navigationService.GoBackAsync();
}
- // ========== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ==========
+ private void EnsureSubscribed()
+ {
+ if (_isSubscribedToJavaScriptRequests)
+ {
+ return;
+ }
+
+ _viewModel.OnJavaScriptRequested += OnJavaScriptRequested;
+ _isSubscribedToJavaScriptRequests = true;
+ }
+
+ private void EnsureUnsubscribed()
+ {
+ if (!_isSubscribedToJavaScriptRequests)
+ {
+ return;
+ }
+
+ _viewModel.OnJavaScriptRequested -= OnJavaScriptRequested;
+ _isSubscribedToJavaScriptRequests = false;
+ }
- ///
- /// Выполняет JavaScript без ожидания результата
- ///
private async Task EvalJsAsync(string script)
{
try
@@ -335,9 +344,6 @@ public partial class ReaderPage : ContentPage
}
}
- ///
- /// Выполняет JavaScript и возвращает результат
- ///
private async Task EvalJsWithResultAsync(string script)
{
string? result = null;
@@ -359,29 +365,16 @@ public partial class ReaderPage : ContentPage
{
System.Diagnostics.Debug.WriteLine($"[Reader] JS result dispatch error: {ex.Message}");
}
+
return result;
}
- ///
- /// Разбивает строку на чанки заданного размера
- ///
- private static List SplitString(string str, int chunkSize)
- {
- var chunks = new List();
- for (int i = 0; i < str.Length; i += chunkSize)
- {
- chunks.Add(str.Substring(i, Math.Min(chunkSize, str.Length - i)));
- }
- return chunks;
- }
-
- ///
- /// Экранирует строку для вставки в JS код (внутри одинарных кавычек)
- ///
private static string EscapeJs(string value)
{
if (string.IsNullOrEmpty(value))
+ {
return string.Empty;
+ }
return value
.Replace("\\", "\\\\")
@@ -392,30 +385,25 @@ public partial class ReaderPage : ContentPage
.Replace("\t", "\\t");
}
- ///
- /// Убирает экранирование из результата EvaluateJavaScriptAsync.
- /// Android WebView оборачивает результат в кавычки и экранирует.
- ///
private static string UnescapeJsResult(string result)
{
if (string.IsNullOrEmpty(result))
+ {
return result;
+ }
- // Убираем обрамляющие кавычки если есть
if (result.StartsWith("\"") && result.EndsWith("\""))
{
result = result.Substring(1, result.Length - 2);
}
- // Убираем экранирование
- result = result
+ return result
.Replace("\\\"", "\"")
.Replace("\\\\", "\\")
.Replace("\\/", "/")
.Replace("\\n", "\n")
.Replace("\\r", "\r")
.Replace("\\t", "\t");
-
- return result;
}
-}
\ No newline at end of file
+}
+
diff --git a/BookReader/Views/SettingsPage.xaml b/BookReader/Views/SettingsPage.xaml
index dfbe018..a61c7b6 100644
--- a/BookReader/Views/SettingsPage.xaml
+++ b/BookReader/Views/SettingsPage.xaml
@@ -1,120 +1,130 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ Title="Настройки"
+ Background="{StaticResource AppBackgroundBrush}">
+
+
+
+
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/BookReader/Views/SettingsPage.xaml.cs b/BookReader/Views/SettingsPage.xaml.cs
index 7bbcba5..d5ed3f3 100644
--- a/BookReader/Views/SettingsPage.xaml.cs
+++ b/BookReader/Views/SettingsPage.xaml.cs
@@ -1,4 +1,4 @@
-using BookReader.ViewModels;
+using BookReader.ViewModels;
namespace BookReader.Views;
@@ -31,14 +31,13 @@ public partial class SettingsPage : ContentPage
{
base.OnDisappearing();
- // Автосохранение при выходе со страницы настроек
try
{
- await _viewModel.SaveSettingsCommand.ExecuteAsync(null);
+ await _viewModel.SaveSilentlyAsync();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error auto-saving settings: {ex.Message}");
}
}
-}
\ No newline at end of file
+}