From 739323069621adb1fd3ad2332713ea0c44175d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Sun, 8 Mar 2026 22:07:04 +0300 Subject: [PATCH] edit_codex --- .gitignore | 3 +- BookReader/AppShell.xaml | 32 +- BookReader/AppShell.xaml.cs | 4 +- BookReader/BookReader.csproj | 11 +- BookReader/Constants.cs | 15 +- .../Platforms/Android/AndroidManifest.xml | 3 +- BookReader/Resources/Images/cloud_tab.svg | 6 + BookReader/Resources/Images/library_tab.svg | 6 + BookReader/Resources/Images/settings_tab.svg | 5 + BookReader/Resources/Raw/wwwroot/index.html | 321 ++++++++------- BookReader/Resources/Styles/AppStyles.xaml | 136 ++++++- BookReader/Services/CalibreWebService.cs | 67 ++-- BookReader/Services/DatabaseService.cs | 89 +++-- BookReader/Services/ICalibreWebService.cs | 4 +- BookReader/Services/ISettingsService.cs | 7 +- BookReader/Services/SettingsService.cs | 27 +- BookReader/ViewModels/BookshelfViewModel.cs | 139 ++++--- .../ViewModels/CalibreLibraryViewModel.cs | 105 +++-- BookReader/ViewModels/ReaderViewModel.cs | 142 +++++-- BookReader/ViewModels/SettingsViewModel.cs | 164 +++++++- BookReader/Views/BookshelfPage.xaml | 369 ++++++++++-------- BookReader/Views/BookshelfPage.xaml.cs | 51 +-- BookReader/Views/CalibreLibraryPage.xaml | 302 +++++++------- BookReader/Views/CalibreLibraryPage.xaml.cs | 7 +- BookReader/Views/ReaderPage.xaml | 297 +++++++------- BookReader/Views/ReaderPage.xaml.cs | 248 ++++++------ BookReader/Views/SettingsPage.xaml | 236 +++++------ BookReader/Views/SettingsPage.xaml.cs | 7 +- 28 files changed, 1686 insertions(+), 1117 deletions(-) create mode 100644 BookReader/Resources/Images/cloud_tab.svg create mode 100644 BookReader/Resources/Images/library_tab.svg create mode 100644 BookReader/Resources/Images/settings_tab.svg 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}"> - - - - - - - - - - - - - - -