edit_codex
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -361,3 +361,4 @@ MigrationBackup/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
.dotnet-cli/
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:views="clr-namespace:BookReader.Views"
|
||||
x:Class="BookReader.AppShell"
|
||||
Shell.FlyoutBehavior="Disabled"
|
||||
Shell.BackgroundColor="#3E2723"
|
||||
Shell.NavBarHasShadow="False"
|
||||
Shell.BackgroundColor="{StaticResource TabBarColor}"
|
||||
Shell.ForegroundColor="White"
|
||||
Shell.TitleColor="White">
|
||||
Shell.TitleColor="White"
|
||||
Shell.TabBarBackgroundColor="{StaticResource TabBarColor}"
|
||||
Shell.TabBarForegroundColor="#D9C3B4"
|
||||
Shell.TabBarTitleColor="#D9C3B4"
|
||||
Shell.TabBarUnselectedColor="#8B7264">
|
||||
|
||||
<ShellContent
|
||||
Title="Library"
|
||||
ContentTemplate="{DataTemplate views:BookshelfPage}"
|
||||
<TabBar>
|
||||
<Tab Title="Библиотека" Icon="library_tab.svg">
|
||||
<ShellContent ContentTemplate="{DataTemplate views:BookshelfPage}"
|
||||
Route="bookshelf" />
|
||||
</Tab>
|
||||
|
||||
<Tab Title="Calibre" Icon="cloud_tab.svg">
|
||||
<ShellContent ContentTemplate="{DataTemplate views:CalibreLibraryPage}"
|
||||
Route="calibre" />
|
||||
</Tab>
|
||||
|
||||
<Tab Title="Настройки" Icon="settings_tab.svg">
|
||||
<ShellContent ContentTemplate="{DataTemplate views:SettingsPage}"
|
||||
Route="settings" />
|
||||
</Tab>
|
||||
</TabBar>
|
||||
</Shell>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -37,13 +37,7 @@
|
||||
<ItemGroup>
|
||||
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<AndroidResource Remove="Platforms\Android\Resources\xml\network_security_config.xml" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ItemGroup>
|
||||
<MauiXaml Update="Resources\Styles\AppStyles.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</MauiXaml>
|
||||
@@ -56,3 +50,4 @@
|
||||
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#3E2723" BaseSize="128,128" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
android:allowBackup="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Maui.Main"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true">
|
||||
</application>
|
||||
|
||||
|
||||
6
BookReader/Resources/Images/cloud_tab.svg
Normal file
6
BookReader/Resources/Images/cloud_tab.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="#A65436" d="M6 17.5A5.5 5.5 0 0 1 6 6.5 6.5 6.5 0 0 1 18.5 8 4.5 4.5 0 1 1 18 17.5z"/>
|
||||
<path fill="#F7E8D9" d="M9 16.5h6.2A3.3 3.3 0 0 0 15 10a4.8 4.8 0 0 0-9.3 1.5A4 4 0 0 0 9 16.5z"/>
|
||||
<path fill="#6F311D" d="M11.25 9.5h1.5v4.2h-1.5z"/>
|
||||
<path fill="#6F311D" d="m9.25 11.7 2.75 2.8 2.75-2.8 1.05 1.05L12 16.55l-3.8-3.8z"/>
|
||||
</svg>
|
||||
6
BookReader/Resources/Images/library_tab.svg
Normal file
6
BookReader/Resources/Images/library_tab.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="#A65436" d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v15.5A2.5 2.5 0 0 0 17.5 16H6.5A2.5 2.5 0 0 0 4 18.5z"/>
|
||||
<path fill="#F7E8D9" d="M6.5 4.5H18.5V14.5H6.5A4 4 0 0 0 4.75 14.9V5.5A1.75 1.75 0 0 1 6.5 3.75z"/>
|
||||
<path fill="#6F311D" d="M7.5 7h8v1.5h-8zm0 3h6.5v1.5H7.5z"/>
|
||||
<path fill="#27160E" d="M6.5 16.75h11A1.75 1.75 0 0 1 19.25 18.5V20H6.5a1.5 1.5 0 0 1 0-3z"/>
|
||||
</svg>
|
||||
5
BookReader/Resources/Images/settings_tab.svg
Normal file
5
BookReader/Resources/Images/settings_tab.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="#A65436" d="M12 2.5 14 5l3-.2.9 2.8L20.5 9 19 12l1.5 3-2.6 1.4-.9 2.8-3-.2-2 2.5-2-2.5-3 .2L5 16.4 2.5 15 4 12 2.5 9 5 7.6l.9-2.8 3 .2z"/>
|
||||
<circle cx="12" cy="12" r="3.2" fill="#F7E8D9"/>
|
||||
<circle cx="12" cy="12" r="1.5" fill="#27160E"/>
|
||||
</svg>
|
||||
@@ -1,35 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Book Reader</title>
|
||||
<style>
|
||||
* { /* Сброс отступов и установка box-sizing для всех элементов */
|
||||
* { /* Сброс отступов и установка box-sizing для всех элементов */
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body { /* Растягиваем страницу на весь экран, отключаем прокрутку и выделение текста */
|
||||
html, body { /* Растягиваем страницу на весь экран, отключаем прокрутку и выделение текста */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: #faf8ef; /* Цвет "сепия" для комфортного чтения */
|
||||
background-color: #faf8ef; /* Цвет "сепия" для комфортного чтения */
|
||||
font-family: serif;
|
||||
-webkit-touch-callout: none; /* Запрет контекстного меню при долгом нажатии (iOS) */
|
||||
-webkit-user-select: none; /* Запрет выделения текста */
|
||||
-webkit-touch-callout: none; /* Запрет контекстного меню при долгом нажатии (iOS) */
|
||||
-webkit-user-select: none; /* Запрет выделения текста */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#reader-container { /* Основной контейнер для книги */
|
||||
#reader-container { /* Основной контейнер для книги */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#book-content, #fb2-content { /* Контейнеры для EPUB и FB2 соответственно */
|
||||
#book-content, #fb2-content { /* Контейнеры для EPUB и FB2 соответственно */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
@@ -37,7 +37,7 @@
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#fb2-content { /* Специфичные настройки для FB2: отступы и скрытие по умолчанию */
|
||||
#fb2-content { /* Специфичные настройки для FB2: отступы и скрытие по умолчанию */
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
font-size: 18px;
|
||||
@@ -45,7 +45,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
#loading { /* Центрированный экран загрузки */
|
||||
#loading { /* Центрированный экран загрузки */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -56,7 +56,7 @@
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.spinner { /* Анимация крутящегося индикатора загрузки */
|
||||
.spinner { /* Анимация крутящегося индикатора загрузки */
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #ddd;
|
||||
@@ -65,13 +65,13 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { /* Описание вращения спиннера */
|
||||
@keyframes spin { /* Описание вращения спиннера */
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#error-display { /* Контейнер для вывода ошибок */
|
||||
#error-display { /* Контейнер для вывода ошибок */
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -84,7 +84,7 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Прозрачные зоны для управления тапами (назад, меню, вперед) */
|
||||
/* Прозрачные зоны для управления тапами (назад, меню, вперед) */
|
||||
.touch-zone {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -96,17 +96,17 @@
|
||||
left: 0;
|
||||
width: 30%;
|
||||
}
|
||||
/* Левая треть экрана — назад */
|
||||
/* Левая треть экрана — назад */
|
||||
#touch-right {
|
||||
right: 0;
|
||||
width: 30%;
|
||||
}
|
||||
/* Правая треть — вперед */
|
||||
/* Правая треть — вперед */
|
||||
#touch-center {
|
||||
left: 30%;
|
||||
width: 40%;
|
||||
}
|
||||
/* Центр — открыть меню */
|
||||
/* Центр — открыть меню */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -116,7 +116,7 @@
|
||||
<span id="loading-text">Initializing...</span>
|
||||
</div>
|
||||
<div id="error-display">
|
||||
<span style="font-size:40px">⚠️</span>
|
||||
<span style="font-size:40px">вљ пёЏ</span>
|
||||
<span id="error-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,20 +126,14 @@
|
||||
|
||||
<script src="_framework/hybridwebview.js"></script>
|
||||
<script src="js/jszip.min.js"></script>
|
||||
<script src="js/epub.min.js"></script> ```
|
||||
|
||||
---
|
||||
|
||||
### JavaScript Логика
|
||||
|
||||
```javascript
|
||||
<script src="js/epub.min.js"></script>
|
||||
<script>
|
||||
(function () { // Самовызывающаяся функция для изоляции переменных
|
||||
(function () { // Самовызывающаяся функция для изоляции переменных
|
||||
'use strict';
|
||||
|
||||
// ========== КЭШИРОВАННЫЕ DOM-ЭЛЕМЕНТЫ ==========
|
||||
const $ = id => document.getElementById(id); // Короткий псевдоним для поиска элементов
|
||||
const els = { // Объект со ссылками на элементы для быстрого доступа
|
||||
// ========== РљРРЁРРОВАННЫЕ DOM-РЛЕМЕНТЫ ==========
|
||||
const $ = id => document.getElementById(id); // Короткий псевдоним для поиска элементов
|
||||
const els = { // Объект со ссылками на элементы для быстрого доступа
|
||||
loading: $('loading'),
|
||||
loadingText: $('loading-text'),
|
||||
errorDisplay: $('error-display'),
|
||||
@@ -148,83 +142,88 @@
|
||||
fb2Content: $('fb2-content'),
|
||||
};
|
||||
|
||||
// ========== СОСТОЯНИЕ (STATE) ==========
|
||||
const state = { // Глобальный объект состояния текущей книги
|
||||
book: null, // Объект книги epub.js
|
||||
rendition: null, // Объект отображения epub.js
|
||||
currentCfi: null, // Текущая позиция (идентификатор) в EPUB
|
||||
totalPages: 0, // Всего страниц
|
||||
bookFormat: '', // epub или fb2
|
||||
// ========== РЎРћРЎРўРћРЇРќРР• (STATE) ==========
|
||||
const state = { // Глобальный объект состояния текущей книги
|
||||
book: null, // Объект книги epub.js
|
||||
rendition: null, // Объект отображения epub.js
|
||||
currentCfi: null, // Текущая позиция (идентификатор) в EPUB
|
||||
totalPages: 0, // Всего страниц
|
||||
bookFormat: '', // epub или fb2
|
||||
isBookLoaded: false,
|
||||
fb2CurrentPage: 0,
|
||||
fb2TotalPages: 1,
|
||||
toc: [], // Оглавление
|
||||
lastCfi: null // Последний CFI
|
||||
toc: [], // Оглавление
|
||||
lastCfi: null,
|
||||
currentPage: 0,
|
||||
currentFontSize: 18,
|
||||
currentFontFamily: 'serif',
|
||||
currentTheme: 'sepia',
|
||||
brightness: 100
|
||||
};
|
||||
|
||||
// ========== УТИЛИТЫ ==========
|
||||
function debugLog(msg) { // Логирование в консоль с префиксом
|
||||
// ========== РЈРўРР›РРўР« ==========
|
||||
function debugLog(msg) { // Логирование в консоль с префиксом
|
||||
console.log('[Reader] ' + msg);
|
||||
}
|
||||
|
||||
function showError(msg) { // Показ экрана ошибки пользователю
|
||||
function showError(msg) { // Показ экрана ошибки пользователю
|
||||
els.loading.style.display = 'none';
|
||||
els.errorDisplay.style.display = 'flex';
|
||||
els.errorText.textContent = msg;
|
||||
debugLog('ERROR: ' + msg);
|
||||
}
|
||||
|
||||
function setLoadingText(msg) { // Обновление текста на экране загрузки
|
||||
function setLoadingText(msg) { // Обновление текста на экране загрузки
|
||||
if (els.loadingText) els.loadingText.textContent = msg;
|
||||
debugLog(msg);
|
||||
}
|
||||
|
||||
const _escDiv = document.createElement('div'); // Буфер для очистки HTML
|
||||
function escapeHtml(text) { // Защита от XSS: превращает < в < и т.д.
|
||||
const _escDiv = document.createElement('div'); // Буфер для очистки HTML
|
||||
function escapeHtml(text) { // Защита от XSS: превращает < в < и т.д.
|
||||
_escDiv.textContent = text;
|
||||
return _escDiv.innerHTML;
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64) { // Конвертация данных из строки (Base64) в бинарный массив
|
||||
const bin = atob(base64); // Декодирование base64
|
||||
function base64ToArrayBuffer(base64) { // Конвертация данных из строки (Base64) в бинарный массив
|
||||
const bin = atob(base64); // Декодирование base64
|
||||
const len = bin.length;
|
||||
const buf = new ArrayBuffer(len);
|
||||
const view = new Uint8Array(buf);
|
||||
for (let i = 0; i < len; i++) {
|
||||
view[i] = bin.charCodeAt(i); // Заполнение массива байтами
|
||||
view[i] = bin.charCodeAt(i); // Заполнение массива байтами
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
function calculateOptimalLocationSize() {
|
||||
// 1. Получаем размеры видимой области
|
||||
// 1. Получаем размеры видимой области
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
// 2. Получаем текущий размер шрифта (из состояния или напрямую из настроек)
|
||||
// Если размер шрифта еще не задан, используем 18px по умолчанию
|
||||
// 2. Получаем текущий размер шрифта (из состояния или напрямую из настроек)
|
||||
// Если размер шрифта еще не задан, используем 18px по умолчанию
|
||||
const fontSize = state.currentFontSize || 18;
|
||||
|
||||
// 3. Эмпирический коэффициент:
|
||||
// На 1 квадратный пиксель при 18-м шрифте приходится примерно 0.005 - 0.007 символа.
|
||||
// Мы рассчитываем "площадь" одного символа.
|
||||
// Чем больше шрифт, тем больше места занимает символ (квадратичная зависимость).
|
||||
// 3. Рмпирический коэффициент:
|
||||
// На 1 квадратный пиксель при 18-м шрифте приходится примерно 0.005 - 0.007 символа.
|
||||
// Мы рассчитываем "площадь" одного символа.
|
||||
// Чем больше шрифт, тем больше места занимает символ (квадратичная зависимость).
|
||||
const charArea = (fontSize * fontSize) * 0.55;
|
||||
|
||||
// 4. Рассчитываем общую вместимость экрана в символах
|
||||
// 4. Рассчитываем общую вместимость экрана в символах
|
||||
const screenArea = width * height;
|
||||
let charactersPerScreen = Math.floor(screenArea / charArea);
|
||||
|
||||
// 5. Ограничиваем значения для стабильности epub.js
|
||||
// Минимум 400 (чтобы не плодить тысячи локаций на маленьких текстах)
|
||||
// Максимум 1500 (чтобы избежать "залипания" на больших экранах)
|
||||
// 5. Ограничиваем значения для стабильности epub.js
|
||||
// Минимум 400 (чтобы не плодить тысячи локаций на маленьких текстах)
|
||||
// Максимум 1500 (чтобы избежать "залипания" на больших экранах)
|
||||
const finalSize = Math.max(400, Math.min(1000, charactersPerScreen));
|
||||
|
||||
debugLog(`Расчет локации: Экран ${width}x${height}, Шрифт ${fontSize}px => Размер локации: ${finalSize}`);
|
||||
debugLog(`Расчет локации: Ркран ${width}x${height}, Шрифт ${fontSize}px => Размер локации: ${finalSize}`);
|
||||
|
||||
return finalSize;
|
||||
}
|
||||
// ========== MESSAGE BRIDGE (Связь с приложением) ==========
|
||||
function sendMessage(action, data) { // Отправка данных в нативный код (C# / Swift / Kotlin)
|
||||
// ========== MESSAGE BRIDGE (Связь с приложением) ==========
|
||||
function sendMessage(action, data) { // Отправка данных в нативный код (C# / Swift / Kotlin)
|
||||
const message = JSON.stringify({ action, data: data || {} });
|
||||
try {
|
||||
if (window.HybridWebView && typeof window.HybridWebView.SendRawMessage === 'function') {
|
||||
@@ -235,39 +234,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ========== TOUCH / SWIPE (Жесты) ==========
|
||||
// ========== TOUCH / SWIPE (Жесты) ==========
|
||||
let swipeStartX = 0, swipeStartY = 0, swipeStartTime = 0;
|
||||
|
||||
const touchActions = { // Действия при клике на зоны
|
||||
const touchActions = { // Действия при клике на зоны
|
||||
'touch-left': () => window.prevPage(),
|
||||
'touch-right': () => window.nextPage(),
|
||||
'touch-center': () => sendMessage('toggleMenu', {}), // Показать меню приложения
|
||||
'touch-center': () => sendMessage('toggleMenu', {}), // Показать меню приложения
|
||||
};
|
||||
|
||||
function initTouchZones() { // Настройка слушателей событий на зоны тача
|
||||
function initTouchZones() { // Настройка слушателей событий на зоны тача
|
||||
Object.keys(touchActions).forEach(id => {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
|
||||
el.addEventListener('click', e => { // Обработка обычного клика
|
||||
el.addEventListener('click', e => { // Обработка обычного клика
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
touchActions[id]();
|
||||
});
|
||||
|
||||
el.addEventListener('touchstart', e => { // Начало касания для свайпа
|
||||
el.addEventListener('touchstart', e => { // Начало касания для свайпа
|
||||
const t = e.touches[0];
|
||||
swipeStartX = t.clientX;
|
||||
swipeStartY = t.clientY;
|
||||
swipeStartTime = Date.now();
|
||||
}, { passive: true });
|
||||
|
||||
el.addEventListener('touchend', e => { // Конец касания: расчет свайпа
|
||||
el.addEventListener('touchend', e => { // Конец касания: расчет свайпа
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - swipeStartX; // Смещение по горизонтали
|
||||
const dy = t.clientY - swipeStartY; // Смещение по вертикали
|
||||
const dt = Date.now() - swipeStartTime; // Время касания
|
||||
// Если быстро (меньше 0.5с), горизонтально и длиннее 50px
|
||||
const dx = t.clientX - swipeStartX; // Смещение по горизонтали
|
||||
const dy = t.clientY - swipeStartY; // Смещение по вертикали
|
||||
const dt = Date.now() - swipeStartTime; // Время касания
|
||||
// Если быстро (меньше 0.5с), горизонтально и длиннее 50px
|
||||
if (dt < 500 && Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 50) {
|
||||
dx < 0 ? window.nextPage() : window.prevPage();
|
||||
}
|
||||
@@ -283,20 +282,20 @@
|
||||
if (typeof JSZip === 'undefined') { showError('JSZip not loaded!'); return; }
|
||||
if (typeof ePub === 'undefined') { showError('epub.js not loaded!'); return; }
|
||||
|
||||
state.book = ePub(arrayBuffer); // Создание объекта книги из данных
|
||||
state.book = ePub(arrayBuffer); // Создание объекта книги из данных
|
||||
state.lastCfi = null;
|
||||
els.fb2Content.style.display = 'none';
|
||||
els.bookContent.style.display = 'block';
|
||||
|
||||
// Рендеринг (отрисовка) книги в контейнер
|
||||
// Рендеринг (отрисовка) книги в контейнер
|
||||
state.rendition = state.book.renderTo('book-content', {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
spread: 'none', // Без двухстраничного режима
|
||||
flow: 'paginated' // Режим постраничного отображения
|
||||
spread: 'none', // Без двухстраничного режима
|
||||
flow: 'paginated' // Режим постраничного отображения
|
||||
});
|
||||
|
||||
// Настройка стилей внутри фрейма книги
|
||||
// Настройка стилей внутри фрейма книги
|
||||
state.rendition.themes.default({
|
||||
'body': {
|
||||
'font-family': 'serif !important',
|
||||
@@ -310,32 +309,32 @@
|
||||
});
|
||||
|
||||
state.book.ready.then(() => {
|
||||
// Убираем экран загрузки СРАЗУ, как только книга готова к отрисовке первой страницы
|
||||
// Убираем экран загрузки СРАЗУ, как только книга готова к отрисовке первой страницы
|
||||
els.loading.style.display = 'none';
|
||||
|
||||
// Обработка оглавления
|
||||
// Обработка оглавления
|
||||
const toc = state.book.navigation.toc || [];
|
||||
state.toc = toc.map(ch => ({ label: ch.label.trim(), href: ch.href }));
|
||||
sendMessage('chaptersLoaded', { chapters: state.toc });
|
||||
|
||||
// ПРОВЕРКА КЭША: Если мы уже передали сохраненные локации
|
||||
// ПРОВЕРКА РљРРЁРђ: Если РјС‹ СѓР¶Рµ передали сохраненные локации
|
||||
if (cachedLocations) {
|
||||
debugLog("Загрузка из кэша...");
|
||||
debugLog("Загрузка из кэша...");
|
||||
state.book.locations.load(cachedLocations);
|
||||
state.totalPages = state.book.locations.length();
|
||||
state.lastCfi = null;
|
||||
sendMessage('bookReady', { totalPages: state.totalPages });
|
||||
} else {
|
||||
// Используем setTimeout, чтобы не блокировать поток отрисовки
|
||||
// Рспользуем setTimeout, чтобы РЅРµ блокировать поток отрисовки
|
||||
const dynamicSize = calculateOptimalLocationSize();
|
||||
// Запускаем генерацию с динамическим размером
|
||||
// Запускаем генерацию с динамическим размером
|
||||
setTimeout(() => {
|
||||
state.book.locations.generate(1000).then(() => {
|
||||
state.totalPages = state.book.locations.length();
|
||||
const locationsToSave = state.book.locations.save();
|
||||
state.lastCfi = null;
|
||||
|
||||
// Отправляем в C#, чтобы сохранить на будущее
|
||||
// Отправляем в C#, чтобы сохранить на будущее
|
||||
sendMessage('saveLocations', { locations: locationsToSave });
|
||||
sendMessage('bookReady', { totalPages: state.totalPages });
|
||||
});
|
||||
@@ -343,32 +342,32 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Событие при смене страницы
|
||||
// Событие при смене страницы
|
||||
state.rendition.on('relocated', location => {
|
||||
if (!location || !location.start) return;
|
||||
const newCfi = location.start.cfi;
|
||||
|
||||
// Получаем процент прогресса
|
||||
// Получаем процент прогресса
|
||||
const progress = state.book.locations.percentageFromCfi(newCfi) || 0;
|
||||
|
||||
// Обновляем lastCfi
|
||||
// Обновляем lastCfi
|
||||
state.lastCfi = newCfi;
|
||||
|
||||
const chapterPage = location.start.displayed ? location.start.displayed.page : 1;
|
||||
const chapterTotal = location.start.displayed ? location.start.displayed.total : 1;
|
||||
const currentHref = location.start.href || '';
|
||||
|
||||
// Поиск человеческого названия текущей главы
|
||||
// Поиск человеческого названия текущей главы
|
||||
let chapterName = currentHref;
|
||||
const foundChapter = state.toc.find(ch => currentHref.includes(ch.href));
|
||||
if (foundChapter) chapterName = foundChapter.label;
|
||||
|
||||
// Отправка прогресса в приложение
|
||||
// currentPage теперь показывает процент (округлённый до целого)
|
||||
// Отправка прогресса в приложение
|
||||
// currentPage теперь показывает процент (округлённый до целого)
|
||||
sendMessage('progressUpdate', {
|
||||
progress: progress,
|
||||
cfi: newCfi,
|
||||
currentPage: Math.round(progress * 100), // Процент вместо номера страницы
|
||||
currentPage: Math.round(progress * 100), // Процент вместо номера страницы
|
||||
totalPages: 100, // 100%
|
||||
chapterCurrentPage: chapterPage,
|
||||
chapterTotalPages: chapterTotal,
|
||||
@@ -376,7 +375,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Отображение книги на сохраненной позиции или в начале
|
||||
// Отображение книги на сохраненной позиции или в начале
|
||||
const displayTarget = (lastCfi && lastCfi !== 'null' && lastCfi !== 'undefined') ? lastCfi : undefined;
|
||||
state.rendition.display(displayTarget);
|
||||
|
||||
@@ -391,7 +390,7 @@
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
let xmlText = new TextDecoder('utf-8').decode(bytes);
|
||||
|
||||
// Проверка кодировки в заголовке XML (если не UTF-8, перекодируем)
|
||||
// Проверка кодировки в заголовке XML (если не UTF-8, перекодируем)
|
||||
const encMatch = xmlText.match(/encoding=["\']([^"\']+)["\']/i);
|
||||
if (encMatch && encMatch[1].toLowerCase() !== 'utf-8') {
|
||||
xmlText = new TextDecoder(encMatch[1]).decode(bytes);
|
||||
@@ -401,18 +400,18 @@
|
||||
els.bookContent.style.display = 'none';
|
||||
els.fb2Content.style.display = 'block';
|
||||
|
||||
// Парсинг строки в XML-документ
|
||||
// Парсинг строки в XML-документ
|
||||
const doc = new DOMParser().parseFromString(xmlText, 'text/xml');
|
||||
if (doc.querySelector('parsererror')) { showError('FB2 XML parse error'); return; }
|
||||
|
||||
const fb2Html = parseFb2Document(doc); // Преобразование FB2 XML в HTML
|
||||
const fb2Html = parseFb2Document(doc); // Преобразование FB2 XML в HTML
|
||||
els.fb2Content.innerHTML = fb2Html.html;
|
||||
els.loading.style.display = 'none';
|
||||
|
||||
// Использование анимационных кадров для замера размеров после вставки в DOM
|
||||
// Рспользование анимационных кадров для замера размеров после вставки РІ DOM
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setupFb2Pagination(); // Нарезка на колонки
|
||||
setupFb2Pagination(); // Нарезка на колонки
|
||||
sendMessage('chaptersLoaded', { chapters: fb2Html.chapters });
|
||||
sendMessage('bookReady', { totalPages: state.totalPages });
|
||||
state.isBookLoaded = true;
|
||||
@@ -424,20 +423,20 @@
|
||||
} catch (e) { showError('FB2 error: ' + e.message); }
|
||||
}
|
||||
|
||||
// ========== FB2 PARSER (Преобразование тегов) ==========
|
||||
function localTag(el) { // Утилита для получения имени тега без пространств имен (типа fb2:)
|
||||
// ========== FB2 PARSER (Преобразование тегов) ==========
|
||||
function localTag(el) { // Утилита для получения имени тега без пространств имен (типа fb2:)
|
||||
return el.tagName ? el.tagName.toLowerCase().replace(/.*:/, '') : '';
|
||||
}
|
||||
|
||||
function parseFb2Document(doc) {
|
||||
const chapters = [];
|
||||
const parts = ['<div id="fb2-inner" style="font-size:18px;font-family:serif;line-height:1.6;">'];
|
||||
let bodies = doc.querySelectorAll('body'); // FB2 может иметь несколько body (основной текст, сноски)
|
||||
let bodies = doc.querySelectorAll('body'); // FB2 может иметь несколько body (основной текст, сноски)
|
||||
|
||||
let chapterIndex = 0;
|
||||
for (const body of bodies) {
|
||||
for (const child of body.children) {
|
||||
if (localTag(child) === 'section') { // Секции — это главы
|
||||
if (localTag(child) === 'section') { // Секции — это главы
|
||||
const result = parseFb2Section(child, chapterIndex);
|
||||
parts.push(result.html);
|
||||
chapters.push(...result.chapters);
|
||||
@@ -449,14 +448,14 @@
|
||||
return { html: parts.join(''), chapters };
|
||||
}
|
||||
|
||||
// Обработчики конкретных тегов FB2
|
||||
// Обработчики конкретных тегов FB2
|
||||
const sectionTagHandlers = {
|
||||
title(child, idx, chapters) { // Заголовки глав
|
||||
title(child, idx, chapters) { // Заголовки глав
|
||||
const text = (child.textContent || '').trim();
|
||||
chapters.push({ label: text, href: idx.toString() });
|
||||
return `<h2 class="fb2-title" data-chapter="${idx}" style="text-align:center;margin:1em 0 .5em">${escapeHtml(text)}</h2>`;
|
||||
},
|
||||
p(child) { // Абзацы
|
||||
p(child) { // Абзацы
|
||||
return `<p style="text-indent:1.5em;margin-bottom:.3em">${getInlineHtml(child)}</p>`;
|
||||
},
|
||||
'empty-line'() { return '<br/>'; },
|
||||
@@ -471,7 +470,7 @@
|
||||
const parts = [`<div class="fb2-section" data-section="${startIndex}">`];
|
||||
for (const child of section.children) {
|
||||
const tag = localTag(child);
|
||||
if (tag === 'section') { // Рекурсия для вложенных секций
|
||||
if (tag === 'section') { // Рекурсия для вложенных секций
|
||||
const sub = parseFb2Section(child, startIndex + chapters.length);
|
||||
parts.push(sub.html);
|
||||
chapters.push(...sub.chapters);
|
||||
@@ -483,11 +482,11 @@
|
||||
return { html: parts.join(''), chapters };
|
||||
}
|
||||
|
||||
function getInlineHtml(el) { // Обработка форматирования внутри строки (жирный, курсив)
|
||||
function getInlineHtml(el) { // Обработка форматирования внутри строки (жирный, курсив)
|
||||
const parts = [];
|
||||
for (const node of el.childNodes) {
|
||||
if (node.nodeType === 3) parts.push(escapeHtml(node.textContent)); // Текст
|
||||
else if (node.nodeType === 1) { // Тег
|
||||
if (node.nodeType === 3) parts.push(escapeHtml(node.textContent)); // Текст
|
||||
else if (node.nodeType === 1) { // Тег
|
||||
const tag = localTag(node);
|
||||
const inner = getInlineHtml(node);
|
||||
if (tag === 'strong' || tag === 'bold') parts.push(`<strong>${inner}</strong>`);
|
||||
@@ -499,17 +498,17 @@
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function parseInnerParagraphs(el) { // Парсинг группы абзацев (для цитат/эпиграфов)
|
||||
function parseInnerParagraphs(el) { // Парсинг группы абзацев (для цитат/эпиграфов)
|
||||
const parts = [];
|
||||
for (const child of el.children) {
|
||||
const tag = localTag(child);
|
||||
if (tag === 'p') parts.push(`<p>${getInlineHtml(child)}</p>`);
|
||||
else if (tag === 'text-author') parts.push(`<p style="text-align:right;font-style:italic">— ${escapeHtml(child.textContent || '')}</p>`);
|
||||
else if (tag === 'text-author') parts.push(`<p style="text-align:right;font-style:italic">— ${escapeHtml(child.textContent || '')}</p>`);
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function parsePoem(el) { // Парсинг стихов (строфы и строки)
|
||||
function parsePoem(el) { // Парсинг стихов (строфы и строки)
|
||||
const parts = [];
|
||||
for (const child of el.children) {
|
||||
const tag = localTag(child);
|
||||
@@ -526,7 +525,7 @@
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
// ========== FB2 PAGINATION (Имитация страниц через CSS Columns) ==========
|
||||
// ========== FB2 PAGINATION (Рмитация страниц через CSS Columns) ==========
|
||||
function setupFb2Pagination() {
|
||||
const container = els.fb2Content;
|
||||
const inner = $('fb2-inner');
|
||||
@@ -535,17 +534,17 @@
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
|
||||
// Основная магия: CSS превращает длинный текст в ряд колонок шириной с экран
|
||||
// Основная магия: CSS превращает длинный текст в ряд колонок шириной с экран
|
||||
Object.assign(inner.style, {
|
||||
columnWidth: w + 'px',
|
||||
columnGap: '40px', // Зазор между колонками
|
||||
columnGap: '40px', // Зазор между колонками
|
||||
columnFill: 'auto',
|
||||
height: h + 'px',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
// Общая ширина контента делить на ширину экрана = количество страниц
|
||||
// Общая ширина контента делить на ширину экрана = количество страниц
|
||||
state.fb2TotalPages = Math.max(1, Math.ceil(inner.scrollWidth / w));
|
||||
state.totalPages = state.fb2TotalPages;
|
||||
state.fb2CurrentPage = 0;
|
||||
@@ -554,17 +553,17 @@
|
||||
});
|
||||
}
|
||||
|
||||
function showFb2Page(idx) { // Переход на конкретную страницу FB2
|
||||
function showFb2Page(idx) { // Переход на конкретную страницу FB2
|
||||
idx = Math.max(0, Math.min(idx, state.fb2TotalPages - 1));
|
||||
state.fb2CurrentPage = idx;
|
||||
const inner = $('fb2-inner');
|
||||
if (inner) {
|
||||
// Сдвигаем контент влево, чтобы показать нужную "колонку"
|
||||
// Сдвигаем контент влево, чтобы показать нужную "колонку"
|
||||
inner.style.transform = `translateX(-${idx * els.fb2Content.clientWidth}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateFb2Progress() { // Оповещение приложения о прогрессе в FB2
|
||||
function updateFb2Progress() { // Оповещение приложения о прогрессе в FB2
|
||||
const total = state.fb2TotalPages;
|
||||
const progress = total > 1 ? state.fb2CurrentPage / (total - 1) : 0;
|
||||
sendMessage('progressUpdate', {
|
||||
@@ -576,7 +575,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
function getCurrentFb2Chapter() { // Поиск заголовка, который сейчас виден на экране
|
||||
function getCurrentFb2Chapter() { // Поиск заголовка, который сейчас виден на экране
|
||||
const inner = $('fb2-inner');
|
||||
const container = els.fb2Content;
|
||||
if (!inner || !container) return '';
|
||||
@@ -590,15 +589,15 @@
|
||||
return chapter;
|
||||
}
|
||||
|
||||
function goToFb2Position(progress) { // Переход по проценту (0.0 - 1.0)
|
||||
function goToFb2Position(progress) { // Переход по проценту (0.0 - 1.0)
|
||||
showFb2Page(Math.round(progress * (state.fb2TotalPages - 1)));
|
||||
}
|
||||
|
||||
// ========== PUBLIC API (Методы, доступные извне, например из C#) ==========
|
||||
// ========== PUBLIC API (Методы, доступные извне, например из C#) ==========
|
||||
window.loadBookFromBase64 = function (base64Data, format, lastPosition, cachedLocations) {
|
||||
state.bookFormat = format;
|
||||
if (format === 'epub') {
|
||||
// Передаем кэш в основную функцию загрузки
|
||||
// Передаем кэш в основную функцию загрузки
|
||||
loadEpubFromBase64(base64Data, lastPosition, cachedLocations);
|
||||
}
|
||||
else if (format === 'fb2') {
|
||||
@@ -607,7 +606,7 @@
|
||||
else showError('Unsupported format: ' + format);
|
||||
};
|
||||
|
||||
window.nextPage = function () { // Листать вперед
|
||||
window.nextPage = function () { // Листать вперед
|
||||
if (state.bookFormat === 'epub' && state.rendition) {
|
||||
state.rendition.next();
|
||||
}
|
||||
@@ -617,7 +616,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
window.prevPage = function () { // Листать назад
|
||||
window.prevPage = function () { // Листать назад
|
||||
if (state.bookFormat === 'epub' && state.rendition) {
|
||||
state.rendition.prev();
|
||||
}
|
||||
@@ -628,17 +627,19 @@
|
||||
};
|
||||
|
||||
window.setFontSize = function (size) { // Изменение размера шрифта
|
||||
state.currentFontSize = size;
|
||||
if (state.bookFormat === 'epub' && state.rendition) state.rendition.themes.fontSize(size + 'px');
|
||||
else if (state.bookFormat === 'fb2') {
|
||||
const inner = $('fb2-inner');
|
||||
if (inner) {
|
||||
inner.style.fontSize = size + 'px';
|
||||
requestAnimationFrame(setupFb2Pagination); // Пересчитать страницы!
|
||||
requestAnimationFrame(setupFb2Pagination);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.setFontFamily = function (family) { // Изменение гарнитуры шрифта
|
||||
state.currentFontFamily = family;
|
||||
if (state.bookFormat === 'epub' && state.rendition) state.rendition.themes.font(family);
|
||||
else if (state.bookFormat === 'fb2') {
|
||||
const inner = $('fb2-inner');
|
||||
@@ -649,7 +650,63 @@
|
||||
}
|
||||
};
|
||||
|
||||
window.goToChapter = function (href) { // Переход к главе из оглавления
|
||||
function getThemePalette(theme) {
|
||||
switch (theme) {
|
||||
case 'dark':
|
||||
return { background: '#181411', color: '#F4E7D8' };
|
||||
case 'light':
|
||||
return { background: '#FFFDF8', color: '#2B1B15' };
|
||||
default:
|
||||
return { background: '#FAF1E2', color: '#33231B' };
|
||||
}
|
||||
}
|
||||
|
||||
function applyReaderTheme() {
|
||||
const palette = getThemePalette(state.currentTheme);
|
||||
document.body.style.backgroundColor = palette.background;
|
||||
els.bookContent.style.backgroundColor = palette.background;
|
||||
els.fb2Content.style.backgroundColor = palette.background;
|
||||
|
||||
if (state.rendition) {
|
||||
state.rendition.themes.default({
|
||||
'body': {
|
||||
'font-family': state.currentFontFamily + ' !important',
|
||||
'font-size': state.currentFontSize + 'px !important',
|
||||
'line-height': '1.6 !important',
|
||||
'padding': '15px !important',
|
||||
'background-color': palette.background + ' !important',
|
||||
'color': palette.color + ' !important'
|
||||
},
|
||||
'p': { 'text-indent': '1.5em', 'margin-bottom': '0.5em' }
|
||||
});
|
||||
state.rendition.themes.fontSize(state.currentFontSize + 'px');
|
||||
state.rendition.themes.font(state.currentFontFamily);
|
||||
}
|
||||
|
||||
const inner = $('fb2-inner');
|
||||
if (inner) {
|
||||
inner.style.backgroundColor = palette.background;
|
||||
inner.style.color = palette.color;
|
||||
}
|
||||
}
|
||||
|
||||
function applyReaderBrightness() {
|
||||
const brightness = Math.max(0.7, Math.min(1.2, state.brightness / 100));
|
||||
els.bookContent.style.filter = `brightness(${brightness})`;
|
||||
els.fb2Content.style.filter = `brightness(${brightness})`;
|
||||
}
|
||||
|
||||
window.setReaderTheme = function (theme) {
|
||||
state.currentTheme = theme || 'sepia';
|
||||
applyReaderTheme();
|
||||
};
|
||||
|
||||
window.setBrightness = function (value) {
|
||||
state.brightness = value || 100;
|
||||
applyReaderBrightness();
|
||||
};
|
||||
|
||||
window.goToChapter = function (href) { // Переход к главе из оглавления
|
||||
if (state.bookFormat === 'epub' && state.rendition) state.rendition.display(href);
|
||||
else if (state.bookFormat === 'fb2') {
|
||||
const el = $('fb2-inner').querySelector(`[data-chapter="${href}"]`);
|
||||
@@ -660,7 +717,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
window.getProgress = function () { // Запрос текущего состояния прогресса (в JSON)
|
||||
window.getProgress = function () { // Запрос текущего состояния прогресса (в JSON)
|
||||
if (state.bookFormat === 'epub' && state.book && state.currentCfi) {
|
||||
try {
|
||||
return JSON.stringify({
|
||||
@@ -680,12 +737,14 @@
|
||||
return '{}';
|
||||
};
|
||||
|
||||
// ========== ИНИЦИАЛИЗАЦИЯ ==========
|
||||
initTouchZones(); // Включаем зоны кликов
|
||||
setLoadingText('Waiting for book...'); // Сообщаем, что готовы принимать файл
|
||||
sendMessage('readerReady', {}); // Уведомляем нативное приложение: "Я загрузился!"
|
||||
// ========== РРќРР¦РРђР›РР—РђР¦РРЇ ==========
|
||||
initTouchZones(); // Включаем зоны кликов
|
||||
setLoadingText('Waiting for book...'); // Сообщаем, что готовы принимать файл
|
||||
sendMessage('readerReady', {}); // Уведомляем нативное приложение: "Я загрузился!"
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
@@ -4,33 +4,141 @@
|
||||
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
|
||||
xmlns:converters="clr-namespace:BookReader.Converters">
|
||||
|
||||
<!-- Converters from CommunityToolkit -->
|
||||
<toolkit:InvertedBoolConverter x:Key="InvertedBoolConverter" />
|
||||
<toolkit:IsStringNotNullOrEmptyConverter x:Key="IsNotNullOrEmptyConverter" />
|
||||
|
||||
<!-- Custom Converters -->
|
||||
<converters:ProgressToWidthConverter x:Key="ProgressToWidthConverter" />
|
||||
<converters:ByteArrayToImageSourceConverter x:Key="ByteArrayToImageConverter" />
|
||||
|
||||
<!-- Colors -->
|
||||
<Color x:Key="PrimaryBrown">#5D4037</Color>
|
||||
<Color x:Key="DarkBrown">#3E2723</Color>
|
||||
<Color x:Key="LightBrown">#8D6E63</Color>
|
||||
<Color x:Key="ShelfColor">#6D4C41</Color>
|
||||
<Color x:Key="AccentGreen">#4CAF50</Color>
|
||||
<Color x:Key="TextPrimary">#EFEBE9</Color>
|
||||
<Color x:Key="TextSecondary">#A1887F</Color>
|
||||
<Color x:Key="AppBackground">#F5E8D6</Color>
|
||||
<Color x:Key="AppBackgroundDeep">#EBC9AE</Color>
|
||||
<Color x:Key="SurfaceColor">#FFF8F0</Color>
|
||||
<Color x:Key="SurfaceMuted">#F7E8D9</Color>
|
||||
<Color x:Key="SurfaceStrong">#EED7C0</Color>
|
||||
<Color x:Key="InkColor">#27160E</Color>
|
||||
<Color x:Key="InkSoftColor">#6E5648</Color>
|
||||
<Color x:Key="BorderColor">#DEC2AA</Color>
|
||||
<Color x:Key="AccentColor">#A65436</Color>
|
||||
<Color x:Key="AccentDarkColor">#6F311D</Color>
|
||||
<Color x:Key="AccentSoftColor">#DFA98D</Color>
|
||||
<Color x:Key="SuccessColor">#2F7D5A</Color>
|
||||
<Color x:Key="WarningColor">#C47A3E</Color>
|
||||
<Color x:Key="DangerColor">#B64932</Color>
|
||||
<Color x:Key="TabBarColor">#2B1B15</Color>
|
||||
|
||||
<LinearGradientBrush x:Key="AppBackgroundBrush" StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="{StaticResource AppBackground}" Offset="0.0" />
|
||||
<GradientStop Color="{StaticResource AppBackgroundDeep}" Offset="1.0" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<!-- Styles -->
|
||||
<Style TargetType="NavigationPage">
|
||||
<Setter Property="BarBackgroundColor" Value="{StaticResource DarkBrown}" />
|
||||
<Setter Property="BarBackgroundColor" Value="{StaticResource TabBarColor}" />
|
||||
<Setter Property="BarTextColor" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Shell">
|
||||
<Setter Property="Shell.BackgroundColor" Value="{StaticResource DarkBrown}" />
|
||||
<Setter Property="Shell.BackgroundColor" Value="{StaticResource TabBarColor}" />
|
||||
<Setter Property="Shell.ForegroundColor" Value="White" />
|
||||
<Setter Property="Shell.TitleColor" Value="White" />
|
||||
<Setter Property="Shell.TabBarBackgroundColor" Value="{StaticResource TabBarColor}" />
|
||||
<Setter Property="Shell.TabBarForegroundColor" Value="#C9B4A6" />
|
||||
<Setter Property="Shell.TabBarTitleColor" Value="#C9B4A6" />
|
||||
<Setter Property="Shell.TabBarUnselectedColor" Value="#9E8474" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PageTitleStyle" TargetType="Label">
|
||||
<Setter Property="FontSize" Value="28" />
|
||||
<Setter Property="FontFamily" Value="OpenSansSemibold" />
|
||||
<Setter Property="TextColor" Value="{StaticResource InkColor}" />
|
||||
<Setter Property="LineBreakMode" Value="WordWrap" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PageSubtitleStyle" TargetType="Label">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="TextColor" Value="{StaticResource InkSoftColor}" />
|
||||
<Setter Property="LineBreakMode" Value="WordWrap" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SectionTitleStyle" TargetType="Label">
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="FontFamily" Value="OpenSansSemibold" />
|
||||
<Setter Property="TextColor" Value="{StaticResource InkColor}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="CaptionStyle" TargetType="Label">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="TextColor" Value="{StaticResource InkSoftColor}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="CardBorderStyle" TargetType="Border">
|
||||
<Setter Property="BackgroundColor" Value="{StaticResource SurfaceColor}" />
|
||||
<Setter Property="Stroke" Value="{StaticResource BorderColor}" />
|
||||
<Setter Property="StrokeThickness" Value="1" />
|
||||
<Setter Property="StrokeShape" Value="RoundRectangle 24" />
|
||||
<Setter Property="Padding" Value="18" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="MutedCardBorderStyle" TargetType="Border">
|
||||
<Setter Property="BackgroundColor" Value="{StaticResource SurfaceMuted}" />
|
||||
<Setter Property="Stroke" Value="{StaticResource BorderColor}" />
|
||||
<Setter Property="StrokeThickness" Value="1" />
|
||||
<Setter Property="StrokeShape" Value="RoundRectangle 24" />
|
||||
<Setter Property="Padding" Value="18" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
|
||||
<Setter Property="BackgroundColor" Value="{StaticResource AccentColor}" />
|
||||
<Setter Property="TextColor" Value="White" />
|
||||
<Setter Property="CornerRadius" Value="18" />
|
||||
<Setter Property="Padding" Value="18,12" />
|
||||
<Setter Property="FontFamily" Value="OpenSansSemibold" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SecondaryButtonStyle" TargetType="Button">
|
||||
<Setter Property="BackgroundColor" Value="{StaticResource SurfaceStrong}" />
|
||||
<Setter Property="TextColor" Value="{StaticResource InkColor}" />
|
||||
<Setter Property="CornerRadius" Value="18" />
|
||||
<Setter Property="Padding" Value="18,12" />
|
||||
<Setter Property="FontFamily" Value="OpenSansSemibold" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="BorderColor" Value="{StaticResource BorderColor}" />
|
||||
<Setter Property="BorderWidth" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="TonalButtonStyle" TargetType="Button">
|
||||
<Setter Property="BackgroundColor" Value="#E9D7C3" />
|
||||
<Setter Property="TextColor" Value="{StaticResource InkColor}" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="Padding" Value="14,10" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="AppSearchBarStyle" TargetType="SearchBar">
|
||||
<Setter Property="BackgroundColor" Value="{StaticResource SurfaceColor}" />
|
||||
<Setter Property="TextColor" Value="{StaticResource InkColor}" />
|
||||
<Setter Property="PlaceholderColor" Value="{StaticResource InkSoftColor}" />
|
||||
<Setter Property="CancelButtonColor" Value="{StaticResource AccentColor}" />
|
||||
<Setter Property="HeightRequest" Value="52" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="AppEntryStyle" TargetType="Entry">
|
||||
<Setter Property="BackgroundColor" Value="{StaticResource SurfaceColor}" />
|
||||
<Setter Property="TextColor" Value="{StaticResource InkColor}" />
|
||||
<Setter Property="PlaceholderColor" Value="{StaticResource InkSoftColor}" />
|
||||
<Setter Property="HeightRequest" Value="52" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="AppPickerStyle" TargetType="Picker">
|
||||
<Setter Property="BackgroundColor" Value="{StaticResource SurfaceColor}" />
|
||||
<Setter Property="TextColor" Value="{StaticResource InkColor}" />
|
||||
<Setter Property="HeightRequest" Value="52" />
|
||||
<Setter Property="TitleColor" Value="{StaticResource InkSoftColor}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="ReaderSliderStyle" TargetType="Slider">
|
||||
<Setter Property="MinimumTrackColor" Value="{StaticResource AccentColor}" />
|
||||
<Setter Property="MaximumTrackColor" Value="{StaticResource SurfaceStrong}" />
|
||||
<Setter Property="ThumbColor" Value="{StaticResource AccentDarkColor}" />
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
@@ -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<bool> TestConnectionAsync(string url, string username, string password)
|
||||
@@ -48,23 +48,26 @@ public class CalibreWebService : ICalibreWebService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<CalibreBook>> GetBooksAsync(string? searchQuery = null, int page = 0, int pageSize = Constants.Network.CalibrePageSize)
|
||||
public async Task<Result<List<CalibreBook>>> GetBooksAsync(string? searchQuery = null, int page = 0, int pageSize = Constants.Network.CalibrePageSize)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_baseUrl))
|
||||
{
|
||||
return Result<List<CalibreBook>>.Failure("Сначала укажите адрес сервера Calibre в настройках.");
|
||||
}
|
||||
|
||||
var books = new List<CalibreBook>();
|
||||
|
||||
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<List<int>>() ?? new List<int>();
|
||||
|
||||
// Параллельная загрузка данных книг с ограничением
|
||||
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<List<CalibreBook>>.Success(books);
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
return Result<List<CalibreBook>>.Failure(new TimeoutException("Сервер Calibre отвечает слишком долго.", ex));
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return Result<List<CalibreBook>>.Failure(new HttpRequestException("Не удалось подключиться к серверу Calibre.", ex));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Error fetching Calibre books: {ex.Message}");
|
||||
return Result<List<CalibreBook>>.Failure(new Exception("Не удалось загрузить каталог Calibre.", ex));
|
||||
}
|
||||
|
||||
return books;
|
||||
}
|
||||
|
||||
private async Task<CalibreBook?> LoadBookDataAsync(int bookId)
|
||||
@@ -98,25 +109,27 @@ public class CalibreWebService : ICalibreWebService
|
||||
var bookJson = JObject.Parse(bookResponse);
|
||||
|
||||
var formats = bookJson["formats"]?.ToObject<List<string>>() ?? new List<string>();
|
||||
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<List<string>>() ?? new List<string>();
|
||||
|
||||
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<string> DownloadBookAsync(CalibreBook book, IProgress<double>? 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,8 +179,10 @@ public class CalibreWebService : ICalibreWebService
|
||||
bytesRead += read;
|
||||
|
||||
if (totalBytes > 0)
|
||||
{
|
||||
progress?.Report((double)bytesRead / totalBytes);
|
||||
}
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
@@ -13,16 +13,16 @@ public class DatabaseService : IDatabaseService
|
||||
|
||||
public DatabaseService()
|
||||
{
|
||||
_dbPath = Path.Combine(FileSystem.AppDataDirectory, "bookreader.db3");
|
||||
_dbPath = Path.Combine(FileSystem.AppDataDirectory, Constants.Database.DatabaseFileName);
|
||||
|
||||
// Polly 8.x retry pipeline: 3 попытки с экспоненциальной задержкой
|
||||
_retryPipeline = new ResiliencePipelineBuilder()
|
||||
.AddRetry(new RetryStrategyOptions
|
||||
{
|
||||
ShouldHandle = new PredicateBuilder().Handle<SQLiteException>()
|
||||
.Handle<Exception>(ex => ex.Message.Contains("database is locked") || ex.Message.Contains("busy")),
|
||||
MaxRetryAttempts = 3,
|
||||
DelayGenerator = context => new ValueTask<TimeSpan?>(TimeSpan.FromMilliseconds(100 * Math.Pow(2, context.AttemptNumber))),
|
||||
MaxRetryAttempts = Constants.Database.RetryCount,
|
||||
DelayGenerator = context => new ValueTask<TimeSpan?>(
|
||||
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<Book>();
|
||||
await _database!.CreateTableAsync<AppSettings>();
|
||||
@@ -49,31 +52,35 @@ public class DatabaseService : IDatabaseService
|
||||
private async Task EnsureInitializedAsync()
|
||||
{
|
||||
if (_database == null)
|
||||
{
|
||||
await InitializeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// Books
|
||||
public async Task<List<Book>> GetAllBooksAsync(CancellationToken ct = default)
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
return await _retryPipeline.ExecuteAsync(async (token) =>
|
||||
await _database!.Table<Book>().OrderByDescending(b => b.LastRead).ToListAsync(), ct);
|
||||
return await _retryPipeline.ExecuteAsync(async _ =>
|
||||
await _database!.Table<Book>().OrderByDescending(book => book.LastRead).ToListAsync(), ct);
|
||||
}
|
||||
|
||||
public async Task<Book?> GetBookByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
return await _retryPipeline.ExecuteAsync(async (token) =>
|
||||
await _database!.Table<Book>().Where(b => b.Id == id).FirstOrDefaultAsync(), ct);
|
||||
return await _retryPipeline.ExecuteAsync(async _ =>
|
||||
await _database!.Table<Book>().Where(book => book.Id == id).FirstOrDefaultAsync(), ct);
|
||||
}
|
||||
|
||||
public async Task<int> 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<int> 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<int> 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<ReadingProgress>().DeleteAsync(p => p.BookId == book.Id), ct);
|
||||
await _retryPipeline.ExecuteAsync(async _ =>
|
||||
await _database!.Table<ReadingProgress>().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<string?> GetSettingAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
return await _retryPipeline.ExecuteAsync(async (token) =>
|
||||
return await _retryPipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
var setting = await _database!.Table<AppSettings>().Where(s => s.Key == key).FirstOrDefaultAsync();
|
||||
var setting = await _database!.Table<AppSettings>().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<AppSettings>().Where(s => s.Key == key).FirstOrDefaultAsync();
|
||||
var existing = await _database!.Table<AppSettings>().Where(item => item.Key == key).FirstOrDefaultAsync();
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Value = value;
|
||||
@@ -135,31 +143,42 @@ public class DatabaseService : IDatabaseService
|
||||
public async Task<Dictionary<string, string>> GetAllSettingsAsync(CancellationToken ct = default)
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
return await _retryPipeline.ExecuteAsync(async (token) =>
|
||||
return await _retryPipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
var settings = await _database!.Table<AppSettings>().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<ReadingProgress?> GetLatestProgressAsync(int bookId, CancellationToken ct = default)
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
return await _retryPipeline.ExecuteAsync(async (token) =>
|
||||
return await _retryPipeline.ExecuteAsync(async _ =>
|
||||
await _database!.Table<ReadingProgress>()
|
||||
.Where(p => p.BookId == bookId)
|
||||
.OrderByDescending(p => p.Timestamp)
|
||||
.Where(progress => progress.BookId == bookId)
|
||||
.OrderByDescending(progress => progress.Timestamp)
|
||||
.FirstOrDefaultAsync(), ct);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace BookReader.Services;
|
||||
public interface ICalibreWebService
|
||||
{
|
||||
Task<bool> TestConnectionAsync(string url, string username, string password);
|
||||
Task<List<CalibreBook>> GetBooksAsync(string? searchQuery = null, int page = 0, int pageSize = 20);
|
||||
Task<Result<List<CalibreBook>>> GetBooksAsync(string? searchQuery = null, int page = 0, int pageSize = 20);
|
||||
Task<string> DownloadBookAsync(CalibreBook book, IProgress<double>? progress = null);
|
||||
void Configure(string url, string username, string password);
|
||||
}
|
||||
@@ -6,9 +6,10 @@ public interface ISettingsService
|
||||
Task SetAsync(string key, string value);
|
||||
Task<int> GetIntAsync(string key, int defaultValue = 0);
|
||||
Task SetIntAsync(string key, int value);
|
||||
Task<double> GetDoubleAsync(string key, double defaultValue = 0);
|
||||
Task SetDoubleAsync(string key, double value);
|
||||
Task<Dictionary<string, string>> GetAllAsync();
|
||||
|
||||
// Secure storage for sensitive data
|
||||
Task SetSecurePasswordAsync(string password);
|
||||
Task<string> GetSecurePasswordAsync();
|
||||
Task ClearSecurePasswordAsync();
|
||||
|
||||
@@ -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<double> 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<Dictionary<string, string>> GetAllAsync()
|
||||
@@ -47,7 +67,7 @@ public class SettingsService : ISettingsService
|
||||
|
||||
public async Task<string> GetSecurePasswordAsync()
|
||||
{
|
||||
return await SecureStorage.Default.GetAsync(SecureStorageKeys.CalibrePassword);
|
||||
return await SecureStorage.Default.GetAsync(SecureStorageKeys.CalibrePassword) ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task ClearSecurePasswordAsync()
|
||||
@@ -56,3 +76,4 @@ public class SettingsService : ISettingsService
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Book> 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<string, object>
|
||||
{
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<CalibreBook> 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);
|
||||
var result = await _calibreWebService.GetBooksAsync(SearchQuery, _currentPage);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
Books.Clear();
|
||||
foreach (var book in books)
|
||||
{
|
||||
Books.Add(book);
|
||||
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<CalibreBook>());
|
||||
}
|
||||
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)
|
||||
{
|
||||
DownloadStatus = result.ErrorMessage ?? "Не удалось подгрузить ещё книги.";
|
||||
return;
|
||||
}
|
||||
|
||||
var existingIds = Books.Select(book => book.Id).ToHashSet();
|
||||
foreach (var book in result.Value ?? new List<CalibreBook>())
|
||||
{
|
||||
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<double>(p =>
|
||||
var progress = new Progress<double>(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<CalibreBook> books)
|
||||
{
|
||||
await _navigationService.GoToAsync("settings");
|
||||
Books.Clear();
|
||||
foreach (var book in books)
|
||||
{
|
||||
Books.Add(book);
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasBooks));
|
||||
}
|
||||
}
|
||||
@@ -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<string> _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<string>? 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,8 +129,10 @@ public partial class ReaderViewModel : BaseViewModel
|
||||
{
|
||||
IsMenuVisible = !IsMenuVisible;
|
||||
if (!IsMenuVisible)
|
||||
{
|
||||
IsChapterListVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void HideMenu()
|
||||
@@ -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,26 +254,31 @@ 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)
|
||||
|
||||
@@ -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<string> 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);
|
||||
|
||||
if (!string.IsNullOrEmpty(CalibreUrl))
|
||||
{
|
||||
_calibreWebService.Configure(CalibreUrl, CalibreUsername, CalibrePassword);
|
||||
await PersistSettingsAsync(showNotification: true);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:BookReader.ViewModels"
|
||||
@@ -6,75 +6,154 @@
|
||||
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
|
||||
x:Class="BookReader.Views.BookshelfPage"
|
||||
x:DataType="vm:BookshelfViewModel"
|
||||
Title="{Binding Title}"
|
||||
Shell.NavBarIsVisible="True">
|
||||
Title="Библиотека"
|
||||
Background="{StaticResource AppBackgroundBrush}">
|
||||
|
||||
<ContentPage.Resources>
|
||||
<toolkit:InvertedBoolConverter x:Key="InvertedBoolConverter" />
|
||||
</ContentPage.Resources>
|
||||
|
||||
<Shell.TitleView>
|
||||
<Grid ColumnDefinitions="*,Auto" Padding="0,0,5,0">
|
||||
<Label Grid.Column="0"
|
||||
Text="📚 Моя книжная полка"
|
||||
FontSize="20"
|
||||
FontAttributes="Bold"
|
||||
VerticalOptions="Center"
|
||||
TextColor="White" />
|
||||
<ImageButton Grid.Column="1"
|
||||
Source="dots_vertical.png"
|
||||
WidthRequest="30"
|
||||
HeightRequest="30"
|
||||
VerticalOptions="Center"
|
||||
Clicked="OnMenuClicked" />
|
||||
</Grid>
|
||||
</Shell.TitleView>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto" BackgroundColor="#3E2723">
|
||||
|
||||
<!-- Search Bar -->
|
||||
<SearchBar Grid.Row="0"
|
||||
Text="{Binding SearchText, Mode=TwoWay}"
|
||||
SearchCommand="{Binding SearchCommand}"
|
||||
SearchCommandParameter="{Binding Text, Source={RelativeSource Self}}"
|
||||
Placeholder="Search by title or author..."
|
||||
PlaceholderColor="#A1887F"
|
||||
TextColor="White"
|
||||
BackgroundColor="#4E342E"
|
||||
Margin="10,5" />
|
||||
|
||||
<!-- Bookshelf Background -->
|
||||
<Grid Grid.Row="1">
|
||||
<!-- Empty state -->
|
||||
<VerticalStackLayout IsVisible="{Binding IsEmpty}"
|
||||
VerticalOptions="Center"
|
||||
HorizontalOptions="Center"
|
||||
Spacing="20"
|
||||
Padding="40">
|
||||
<Label Text="📖"
|
||||
FontSize="80"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="Ваша книжная полка пуста"
|
||||
FontSize="20"
|
||||
TextColor="#D7CCC8"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="Добавьте книгу из Вашего устроиства или библиотеки Calibre"
|
||||
FontSize="14"
|
||||
TextColor="#A1887F"
|
||||
HorizontalOptions="Center"
|
||||
HorizontalTextAlignment="Center" />
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<VerticalStackLayout Grid.Row="0"
|
||||
Padding="20,24,20,14"
|
||||
Spacing="16">
|
||||
<VerticalStackLayout Spacing="6">
|
||||
<Label Text="Личная полка"
|
||||
Style="{StaticResource PageTitleStyle}" />
|
||||
<Label Text="Соберите библиотеку, возвращайтесь к начатому и держите Calibre под рукой."
|
||||
Style="{StaticResource PageSubtitleStyle}" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<!-- Book Collection -->
|
||||
<CollectionView ItemsSource="{Binding Books}"
|
||||
<Border Style="{StaticResource CardBorderStyle}">
|
||||
<SearchBar Text="{Binding SearchText, Mode=TwoWay}"
|
||||
SearchCommand="{Binding SearchCommand}"
|
||||
SearchCommandParameter="{Binding Text, Source={RelativeSource Self}}"
|
||||
Placeholder="Название или автор"
|
||||
Style="{StaticResource AppSearchBarStyle}" />
|
||||
</Border>
|
||||
</VerticalStackLayout>
|
||||
|
||||
<Grid Grid.Row="1">
|
||||
<VerticalStackLayout IsVisible="{Binding IsEmpty}"
|
||||
Padding="20,0,20,24"
|
||||
VerticalOptions="Center"
|
||||
HorizontalOptions="Fill"
|
||||
Spacing="16">
|
||||
<Border Style="{StaticResource CardBorderStyle}">
|
||||
<VerticalStackLayout Spacing="14">
|
||||
<Label Text="Библиотека пока пуста"
|
||||
Style="{StaticResource SectionTitleStyle}" />
|
||||
<Label Text="Добавьте EPUB или FB2 с устройства либо подключитесь к Calibre, чтобы собрать первую полку."
|
||||
Style="{StaticResource PageSubtitleStyle}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
|
||||
<Button Grid.Column="0"
|
||||
Text="Добавить файл"
|
||||
Style="{StaticResource PrimaryButtonStyle}"
|
||||
Command="{Binding AddBookFromFileCommand}" />
|
||||
<Button Grid.Column="1"
|
||||
Text="Открыть Calibre"
|
||||
Style="{StaticResource SecondaryButtonStyle}"
|
||||
Command="{Binding OpenCalibreLibraryCommand}" />
|
||||
</Grid>
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
</VerticalStackLayout>
|
||||
|
||||
<CollectionView x:Name="BooksCollectionView"
|
||||
IsVisible="{Binding IsEmpty, Converter={StaticResource InvertedBoolConverter}}"
|
||||
ItemsSource="{Binding Books}"
|
||||
SelectionMode="None"
|
||||
Margin="10">
|
||||
Margin="0,0,0,24">
|
||||
<CollectionView.Header>
|
||||
<VerticalStackLayout Padding="20,0,20,18"
|
||||
Spacing="16">
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
|
||||
<Border Style="{StaticResource CardBorderStyle}">
|
||||
<VerticalStackLayout Spacing="6">
|
||||
<Label Text="Библиотека"
|
||||
Style="{StaticResource CaptionStyle}" />
|
||||
<Label Text="{Binding LibrarySummary}"
|
||||
Style="{StaticResource SectionTitleStyle}" />
|
||||
<Label Text="{Binding BooksInProgress, StringFormat='В процессе: {0}'}"
|
||||
Style="{StaticResource CaptionStyle}" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource MutedCardBorderStyle}">
|
||||
<VerticalStackLayout Spacing="6">
|
||||
<Label Text="Прогресс"
|
||||
Style="{StaticResource CaptionStyle}" />
|
||||
<Label Text="{Binding CompletedBooks, StringFormat='{0} завершено'}"
|
||||
Style="{StaticResource SectionTitleStyle}" />
|
||||
<Label Text="{Binding BooksInProgress, StringFormat='{0} ещё читаете'}"
|
||||
Style="{StaticResource CaptionStyle}" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border Style="{StaticResource CardBorderStyle}"
|
||||
IsVisible="{Binding HasContinueReading}">
|
||||
<Grid ColumnDefinitions="76,*,Auto"
|
||||
ColumnSpacing="14">
|
||||
<Border Grid.Column="0"
|
||||
BackgroundColor="{StaticResource SurfaceStrong}"
|
||||
StrokeThickness="0"
|
||||
StrokeShape="RoundRectangle 16"
|
||||
HeightRequest="108"
|
||||
WidthRequest="76"
|
||||
Padding="0">
|
||||
<Image Source="{Binding ContinueReadingBook.CoverImage, Converter={StaticResource ByteArrayToImageConverter}}"
|
||||
Aspect="AspectFill" />
|
||||
</Border>
|
||||
|
||||
<VerticalStackLayout Grid.Column="1"
|
||||
VerticalOptions="Center"
|
||||
Spacing="4">
|
||||
<Label Text="Продолжить чтение"
|
||||
Style="{StaticResource CaptionStyle}" />
|
||||
<Label Text="{Binding ContinueReadingBook.Title}"
|
||||
Style="{StaticResource SectionTitleStyle}"
|
||||
MaxLines="2"
|
||||
LineBreakMode="TailTruncation" />
|
||||
<Label Text="{Binding ContinueReadingBook.Author}"
|
||||
Style="{StaticResource CaptionStyle}"
|
||||
MaxLines="1"
|
||||
LineBreakMode="TailTruncation" />
|
||||
<Label Text="{Binding ContinueReadingBook.ProgressText}"
|
||||
FontSize="13"
|
||||
TextColor="{StaticResource SuccessColor}" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<Button Grid.Column="2"
|
||||
Text="Открыть"
|
||||
Style="{StaticResource PrimaryButtonStyle}"
|
||||
Padding="16,10"
|
||||
VerticalOptions="Center"
|
||||
Command="{Binding OpenBookCommand}"
|
||||
CommandParameter="{Binding ContinueReadingBook}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
|
||||
<Button Grid.Column="0"
|
||||
Text="Добавить файл"
|
||||
Style="{StaticResource PrimaryButtonStyle}"
|
||||
Command="{Binding AddBookFromFileCommand}" />
|
||||
<Button Grid.Column="1"
|
||||
Text="Каталог Calibre"
|
||||
Style="{StaticResource SecondaryButtonStyle}"
|
||||
Command="{Binding OpenCalibreLibraryCommand}" />
|
||||
</Grid>
|
||||
</VerticalStackLayout>
|
||||
</CollectionView.Header>
|
||||
|
||||
<CollectionView.ItemsLayout>
|
||||
<GridItemsLayout Orientation="Vertical"
|
||||
Span="3"
|
||||
HorizontalItemSpacing="10"
|
||||
VerticalItemSpacing="15" />
|
||||
<GridItemsLayout x:Name="BooksGridLayout"
|
||||
Orientation="Vertical"
|
||||
Span="2"
|
||||
HorizontalItemSpacing="14"
|
||||
VerticalItemSpacing="14" />
|
||||
</CollectionView.ItemsLayout>
|
||||
|
||||
<CollectionView.ItemTemplate>
|
||||
@@ -82,116 +161,68 @@
|
||||
<SwipeView>
|
||||
<SwipeView.RightItems>
|
||||
<SwipeItems>
|
||||
<SwipeItem Text="Delete"
|
||||
BackgroundColor="#D32F2F"
|
||||
<SwipeItem Text="Удалить"
|
||||
BackgroundColor="{StaticResource DangerColor}"
|
||||
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:BookshelfViewModel}}, Path=DeleteBookCommand}"
|
||||
CommandParameter="{Binding .}" />
|
||||
</SwipeItems>
|
||||
</SwipeView.RightItems>
|
||||
|
||||
<Frame Padding="0"
|
||||
CornerRadius="8"
|
||||
BackgroundColor="#5D4037"
|
||||
HasShadow="True"
|
||||
BorderColor="Transparent">
|
||||
<Frame.GestureRecognizers>
|
||||
<TapGestureRecognizer
|
||||
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:BookshelfViewModel}}, Path=OpenBookCommand}"
|
||||
<Border BackgroundColor="{StaticResource SurfaceColor}"
|
||||
Stroke="{StaticResource BorderColor}"
|
||||
StrokeThickness="1"
|
||||
StrokeShape="RoundRectangle 22">
|
||||
<Border.GestureRecognizers>
|
||||
<TapGestureRecognizer Command="{Binding Source={RelativeSource AncestorType={x:Type vm:BookshelfViewModel}}, Path=OpenBookCommand}"
|
||||
CommandParameter="{Binding .}" />
|
||||
</Frame.GestureRecognizers>
|
||||
</Border.GestureRecognizers>
|
||||
|
||||
<Grid RowDefinitions="150,Auto,Auto,Auto" Padding="0">
|
||||
<!-- Cover Image -->
|
||||
<Frame Grid.Row="0"
|
||||
Padding="0"
|
||||
IsClippedToBounds="True"
|
||||
HasShadow="False"
|
||||
BorderColor="Transparent">
|
||||
<Grid RowDefinitions="168,Auto,Auto,Auto"
|
||||
Padding="0">
|
||||
<Border Grid.Row="0"
|
||||
StrokeThickness="0"
|
||||
StrokeShape="RoundRectangle 22,22,0,0"
|
||||
BackgroundColor="{StaticResource SurfaceStrong}">
|
||||
<Image Source="{Binding CoverImage, Converter={StaticResource ByteArrayToImageConverter}}"
|
||||
Aspect="AspectFill"
|
||||
HeightRequest="150" />
|
||||
</Frame>
|
||||
HeightRequest="168" />
|
||||
</Border>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<Grid Grid.Row="0"
|
||||
<ProgressBar Grid.Row="0"
|
||||
Progress="{Binding ReadingProgress}"
|
||||
ProgressColor="{StaticResource SuccessColor}"
|
||||
BackgroundColor="#33FFFFFF"
|
||||
VerticalOptions="End"
|
||||
HeightRequest="4"
|
||||
BackgroundColor="#44000000">
|
||||
<BoxView BackgroundColor="#4CAF50"
|
||||
HorizontalOptions="Start"
|
||||
WidthRequest="{Binding ReadingProgress, Converter={StaticResource ProgressToWidthConverter}}" />
|
||||
</Grid>
|
||||
HeightRequest="6" />
|
||||
|
||||
<!-- Title -->
|
||||
<Label Grid.Row="1"
|
||||
Margin="12,12,12,0"
|
||||
Text="{Binding Title}"
|
||||
FontSize="11"
|
||||
FontAttributes="Bold"
|
||||
TextColor="#EFEBE9"
|
||||
LineBreakMode="TailTruncation"
|
||||
FontFamily="OpenSansSemibold"
|
||||
FontSize="14"
|
||||
TextColor="{StaticResource InkColor}"
|
||||
MaxLines="2"
|
||||
Padding="5,5,5,0" />
|
||||
LineBreakMode="TailTruncation" />
|
||||
|
||||
<!-- Author -->
|
||||
<Label Grid.Row="2"
|
||||
Margin="12,4,12,0"
|
||||
Text="{Binding Author}"
|
||||
FontSize="9"
|
||||
TextColor="#A1887F"
|
||||
LineBreakMode="TailTruncation"
|
||||
FontSize="12"
|
||||
TextColor="{StaticResource InkSoftColor}"
|
||||
MaxLines="1"
|
||||
Padding="5,0,5,2" />
|
||||
LineBreakMode="TailTruncation" />
|
||||
|
||||
<!-- Progress Text -->
|
||||
<Label Grid.Row="3"
|
||||
Margin="12,6,12,12"
|
||||
Text="{Binding ProgressText}"
|
||||
FontSize="9"
|
||||
TextColor="#81C784"
|
||||
Padding="5,0,5,5" />
|
||||
FontSize="12"
|
||||
TextColor="{StaticResource AccentDarkColor}" />
|
||||
</Grid>
|
||||
</Frame>
|
||||
</Border>
|
||||
</SwipeView>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
</CollectionView>
|
||||
</Grid>
|
||||
|
||||
<!-- Bottom Shelf / Action Bar -->
|
||||
<Grid Grid.Row="2"
|
||||
BackgroundColor="#4E342E"
|
||||
Padding="15,10"
|
||||
ColumnDefinitions="*,Auto,Auto">
|
||||
|
||||
<!-- Shelf decoration -->
|
||||
<BoxView Grid.ColumnSpan="3"
|
||||
BackgroundColor="#6D4C41"
|
||||
HeightRequest="3"
|
||||
VerticalOptions="Start"
|
||||
Margin="0,-10,0,0" />
|
||||
|
||||
<Label Grid.Column="0"
|
||||
Text="{Binding Books.Count, StringFormat='{0} books'}"
|
||||
TextColor="#A1887F"
|
||||
FontSize="12"
|
||||
VerticalOptions="Center" />
|
||||
|
||||
<Button Grid.Column="1"
|
||||
Text="📁 Add File"
|
||||
BackgroundColor="#6D4C41"
|
||||
TextColor="White"
|
||||
FontSize="12"
|
||||
CornerRadius="20"
|
||||
Padding="15,8"
|
||||
Margin="5,0"
|
||||
Command="{Binding AddBookFromFileCommand}" />
|
||||
|
||||
<Button Grid.Column="2"
|
||||
Text="☁️ Calibre"
|
||||
BackgroundColor="#6D4C41"
|
||||
TextColor="White"
|
||||
FontSize="12"
|
||||
CornerRadius="20"
|
||||
Padding="15,8"
|
||||
Command="{Binding OpenCalibreLibraryCommand}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
||||
@@ -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);
|
||||
@@ -30,30 +30,31 @@ public partial class BookshelfPage : ContentPage
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
private void UpdateGridSpan(double availableWidth)
|
||||
{
|
||||
if (availableWidth <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var span = availableWidth switch
|
||||
{
|
||||
< 520 => 2,
|
||||
< 920 => 3,
|
||||
_ => 4
|
||||
};
|
||||
|
||||
BooksGridLayout.Span = span;
|
||||
}
|
||||
}
|
||||
@@ -1,163 +1,169 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:BookReader.ViewModels"
|
||||
xmlns:models="clr-namespace:BookReader.Models"
|
||||
xmlns:converters="clr-namespace:BookReader.Converters"
|
||||
x:Class="BookReader.Views.CalibreLibraryPage"
|
||||
x:DataType="vm:CalibreLibraryViewModel"
|
||||
Title="{Binding Title}"
|
||||
BackgroundColor="#1E1E1E">
|
||||
Title="Calibre"
|
||||
Background="{StaticResource AppBackgroundBrush}">
|
||||
|
||||
<ContentPage.Resources>
|
||||
<converters:ByteArrayToImageSourceConverter x:Key="ByteArrayToImageConverter" />
|
||||
</ContentPage.Resources>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
|
||||
<!-- Not Configured Message -->
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<VerticalStackLayout Grid.Row="0"
|
||||
IsVisible="{Binding IsConfigured, Converter={StaticResource InvertedBoolConverter}}"
|
||||
Padding="30"
|
||||
Spacing="15"
|
||||
VerticalOptions="Center">
|
||||
<Label Text="☁️ Calibre-Web not configured"
|
||||
FontSize="20"
|
||||
TextColor="White"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="Please configure your Calibre-Web server in Settings"
|
||||
FontSize="14"
|
||||
TextColor="#B0B0B0"
|
||||
HorizontalOptions="Center"
|
||||
HorizontalTextAlignment="Center" />
|
||||
<Button Text="Open Settings"
|
||||
BackgroundColor="#5D4037"
|
||||
TextColor="White"
|
||||
CornerRadius="8"
|
||||
Command="{Binding OpenSettingsCommand}"
|
||||
HorizontalOptions="Center" />
|
||||
Padding="20,24,20,14"
|
||||
Spacing="16">
|
||||
<VerticalStackLayout Spacing="6">
|
||||
<Label Text="Каталог Calibre"
|
||||
Style="{StaticResource PageTitleStyle}" />
|
||||
<Label Text="Ищите книги на сервере, скачивайте их в одно касание и держите онлайн-каталог рядом с локальной полкой."
|
||||
Style="{StaticResource PageSubtitleStyle}" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<!-- Connection Error Message (Offline Mode) -->
|
||||
<VerticalStackLayout Grid.Row="0"
|
||||
IsVisible="{Binding IsConfigured}"
|
||||
Padding="30"
|
||||
Spacing="15"
|
||||
VerticalOptions="Center">
|
||||
<Label Text="⚠️ Connection failed"
|
||||
FontSize="20"
|
||||
TextColor="#FF7043"
|
||||
HorizontalOptions="Center"
|
||||
IsVisible="{Binding ConnectionErrorMessage, Converter={StaticResource IsNotNullOrEmptyConverter}}" />
|
||||
<Border Style="{StaticResource CardBorderStyle}"
|
||||
IsVisible="{Binding IsConfigured}">
|
||||
<SearchBar Text="{Binding SearchQuery}"
|
||||
Placeholder="Поиск по Calibre"
|
||||
SearchCommand="{Binding SearchCommand}"
|
||||
Style="{StaticResource AppSearchBarStyle}" />
|
||||
</Border>
|
||||
</VerticalStackLayout>
|
||||
|
||||
<Grid Grid.Row="1">
|
||||
<VerticalStackLayout IsVisible="{Binding IsConfigured, Converter={StaticResource InvertedBoolConverter}}"
|
||||
Padding="20,0,20,24"
|
||||
VerticalOptions="Center"
|
||||
Spacing="16">
|
||||
<Border Style="{StaticResource CardBorderStyle}">
|
||||
<VerticalStackLayout Spacing="12">
|
||||
<Label Text="Calibre ещё не настроен"
|
||||
Style="{StaticResource SectionTitleStyle}" />
|
||||
<Label Text="Укажите адрес сервера, логин и пароль в настройках, после чего каталог появится здесь."
|
||||
Style="{StaticResource PageSubtitleStyle}" />
|
||||
<Button Text="Открыть настройки"
|
||||
Style="{StaticResource PrimaryButtonStyle}"
|
||||
Command="{Binding OpenSettingsCommand}" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
</VerticalStackLayout>
|
||||
|
||||
<VerticalStackLayout IsVisible="{Binding HasConnectionError}"
|
||||
Padding="20,0,20,24"
|
||||
VerticalOptions="Center"
|
||||
Spacing="16">
|
||||
<Border Style="{StaticResource CardBorderStyle}">
|
||||
<VerticalStackLayout Spacing="12">
|
||||
<Label Text="Подключение не удалось"
|
||||
Style="{StaticResource SectionTitleStyle}" />
|
||||
<Label Text="{Binding ConnectionErrorMessage}"
|
||||
FontSize="14"
|
||||
TextColor="#B0B0B0"
|
||||
HorizontalOptions="Center"
|
||||
HorizontalTextAlignment="Center"
|
||||
IsVisible="{Binding ConnectionErrorMessage, Converter={StaticResource IsNotNullOrEmptyConverter}}" />
|
||||
<Button Text="🔄 Retry"
|
||||
BackgroundColor="#5D4037"
|
||||
TextColor="White"
|
||||
CornerRadius="8"
|
||||
Command="{Binding RefreshBooksCommand}"
|
||||
HorizontalOptions="Center"
|
||||
IsVisible="{Binding ConnectionErrorMessage, Converter={StaticResource IsNotNullOrEmptyConverter}}" />
|
||||
Style="{StaticResource PageSubtitleStyle}" />
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
|
||||
<Button Grid.Column="0"
|
||||
Text="Повторить"
|
||||
Style="{StaticResource PrimaryButtonStyle}"
|
||||
Command="{Binding RefreshBooksCommand}" />
|
||||
<Button Grid.Column="1"
|
||||
Text="Настройки"
|
||||
Style="{StaticResource SecondaryButtonStyle}"
|
||||
Command="{Binding OpenSettingsCommand}" />
|
||||
</Grid>
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
</VerticalStackLayout>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<SearchBar Grid.Row="1"
|
||||
IsVisible="{Binding IsConfigured}"
|
||||
Text="{Binding SearchQuery}"
|
||||
Placeholder="Search books..."
|
||||
PlaceholderColor="#666"
|
||||
TextColor="White"
|
||||
BackgroundColor="#2C2C2C"
|
||||
SearchCommand="{Binding SearchCommand}" />
|
||||
|
||||
<!-- Book List -->
|
||||
<RefreshView Grid.Row="2"
|
||||
IsVisible="{Binding ConnectionErrorMessage, Converter={StaticResource InvertedBoolConverter}}"
|
||||
<RefreshView IsVisible="{Binding IsConfigured}"
|
||||
IsEnabled="{Binding HasConnectionError, Converter={StaticResource InvertedBoolConverter}}"
|
||||
Command="{Binding RefreshBooksCommand}"
|
||||
IsRefreshing="{Binding IsRefreshing}">
|
||||
IsRefreshing="{Binding IsRefreshing}"
|
||||
Margin="0,0,0,24">
|
||||
<CollectionView ItemsSource="{Binding Books}"
|
||||
SelectionMode="None"
|
||||
RemainingItemsThreshold="5"
|
||||
RemainingItemsThresholdReachedCommand="{Binding LoadMoreBooksCommand}">
|
||||
<CollectionView.EmptyView>
|
||||
<VerticalStackLayout Padding="20"
|
||||
Spacing="10">
|
||||
<Border Style="{StaticResource CardBorderStyle}">
|
||||
<VerticalStackLayout Spacing="8">
|
||||
<Label Text="Ничего не найдено"
|
||||
Style="{StaticResource SectionTitleStyle}" />
|
||||
<Label Text="Измените запрос или обновите каталог, если на сервере появились новые книги."
|
||||
Style="{StaticResource PageSubtitleStyle}" />
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
</VerticalStackLayout>
|
||||
</CollectionView.EmptyView>
|
||||
|
||||
<CollectionView.Header>
|
||||
<VerticalStackLayout Padding="20,0,20,16"
|
||||
Spacing="12">
|
||||
<Border Style="{StaticResource MutedCardBorderStyle}"
|
||||
IsVisible="{Binding DownloadStatus, Converter={StaticResource IsNotNullOrEmptyConverter}}">
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="12">
|
||||
<Label Text="{Binding DownloadStatus}"
|
||||
Style="{StaticResource PageSubtitleStyle}"
|
||||
VerticalOptions="Center" />
|
||||
<ActivityIndicator Grid.Column="1"
|
||||
IsRunning="{Binding IsBusy}"
|
||||
IsVisible="{Binding IsBusy}"
|
||||
Color="{StaticResource AccentColor}"
|
||||
HeightRequest="22"
|
||||
WidthRequest="22" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</VerticalStackLayout>
|
||||
</CollectionView.Header>
|
||||
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:CalibreBook">
|
||||
<Frame Margin="10,5"
|
||||
Padding="10"
|
||||
BackgroundColor="#2C2C2C"
|
||||
CornerRadius="10"
|
||||
HasShadow="True"
|
||||
BorderColor="Transparent">
|
||||
<Grid ColumnDefinitions="80,*,Auto" ColumnSpacing="12">
|
||||
<!-- Cover -->
|
||||
<Frame Grid.Column="0"
|
||||
Padding="0"
|
||||
CornerRadius="6"
|
||||
IsClippedToBounds="True"
|
||||
HasShadow="False"
|
||||
BorderColor="Transparent"
|
||||
HeightRequest="110"
|
||||
WidthRequest="80">
|
||||
<Border BackgroundColor="{StaticResource SurfaceColor}"
|
||||
Stroke="{StaticResource BorderColor}"
|
||||
StrokeThickness="1"
|
||||
StrokeShape="RoundRectangle 24"
|
||||
Margin="20,0,20,14"
|
||||
Padding="14">
|
||||
<Grid ColumnDefinitions="84,*,Auto"
|
||||
ColumnSpacing="14">
|
||||
<Border Grid.Column="0"
|
||||
BackgroundColor="{StaticResource SurfaceStrong}"
|
||||
StrokeThickness="0"
|
||||
StrokeShape="RoundRectangle 16"
|
||||
HeightRequest="118"
|
||||
WidthRequest="84"
|
||||
Padding="0">
|
||||
<Image Source="{Binding CoverImage, Converter={StaticResource ByteArrayToImageConverter}}"
|
||||
Aspect="AspectFill" />
|
||||
</Frame>
|
||||
</Border>
|
||||
|
||||
<!-- Info -->
|
||||
<VerticalStackLayout Grid.Column="1"
|
||||
VerticalOptions="Center"
|
||||
Spacing="4">
|
||||
Spacing="4"
|
||||
VerticalOptions="Center">
|
||||
<Label Text="{Binding Title}"
|
||||
FontSize="15"
|
||||
FontAttributes="Bold"
|
||||
TextColor="White"
|
||||
Style="{StaticResource SectionTitleStyle}"
|
||||
MaxLines="2"
|
||||
LineBreakMode="TailTruncation" />
|
||||
<Label Text="{Binding Author}"
|
||||
Style="{StaticResource PageSubtitleStyle}"
|
||||
MaxLines="2"
|
||||
LineBreakMode="TailTruncation" />
|
||||
<Label Text="{Binding Format, StringFormat='Формат: {0}'}"
|
||||
FontSize="12"
|
||||
TextColor="#A1887F" />
|
||||
<Label Text="{Binding Format, StringFormat='Format: {0}'}"
|
||||
FontSize="11"
|
||||
TextColor="#81C784" />
|
||||
TextColor="{StaticResource SuccessColor}" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<!-- Download Button -->
|
||||
<Button Grid.Column="2"
|
||||
Text="⬇️"
|
||||
FontSize="20"
|
||||
BackgroundColor="#4CAF50"
|
||||
TextColor="White"
|
||||
CornerRadius="25"
|
||||
WidthRequest="50"
|
||||
HeightRequest="50"
|
||||
Text="Скачать"
|
||||
Style="{StaticResource PrimaryButtonStyle}"
|
||||
Padding="16,10"
|
||||
VerticalOptions="Center"
|
||||
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:CalibreLibraryViewModel}}, Path=DownloadBookCommand}"
|
||||
CommandParameter="{Binding .}" />
|
||||
</Grid>
|
||||
</Frame>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
</CollectionView>
|
||||
</RefreshView>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<Grid Grid.Row="3"
|
||||
BackgroundColor="#2C2C2C"
|
||||
Padding="15,8"
|
||||
IsVisible="{Binding IsConfigured}">
|
||||
<Label Text="{Binding DownloadStatus}"
|
||||
TextColor="#81C784"
|
||||
FontSize="12"
|
||||
VerticalOptions="Center" />
|
||||
<ActivityIndicator IsRunning="{Binding IsBusy}"
|
||||
IsVisible="{Binding IsBusy}"
|
||||
Color="#FF8A65"
|
||||
HorizontalOptions="End"
|
||||
HeightRequest="20"
|
||||
WidthRequest="20" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
@@ -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;
|
||||
|
||||
@@ -1,165 +1,194 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:BookReader.ViewModels"
|
||||
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
|
||||
xmlns:converters="clr-namespace:BookReader.Converters"
|
||||
x:Class="BookReader.Views.ReaderPage"
|
||||
x:DataType="vm:ReaderViewModel"
|
||||
Shell.NavBarIsVisible="False"
|
||||
NavigationPage.HasNavigationBar="False">
|
||||
NavigationPage.HasNavigationBar="False"
|
||||
BackgroundColor="#1B130F">
|
||||
|
||||
<ContentPage.Resources>
|
||||
<toolkit:InvertedBoolConverter x:Key="InvertedBoolConverter" />
|
||||
</ContentPage.Resources>
|
||||
|
||||
<Grid>
|
||||
<!-- Рендер книги -->
|
||||
<HybridWebView x:Name="ReaderWebView"
|
||||
RawMessageReceived="OnRawMessageReceived"
|
||||
DefaultFile="index.html"
|
||||
HorizontalOptions="Fill"
|
||||
VerticalOptions="Fill" />
|
||||
|
||||
<!-- Всплывающее меню -->
|
||||
<Grid IsVisible="{Binding IsMenuVisible}"
|
||||
BackgroundColor="Transparent"
|
||||
BackgroundColor="#73000000"
|
||||
InputTransparent="False">
|
||||
<Grid.GestureRecognizers>
|
||||
<TapGestureRecognizer Command="{Binding HideMenuCommand}" />
|
||||
</Grid.GestureRecognizers>
|
||||
<!-- Верхняя всплывающая панель -->
|
||||
<Frame BackgroundColor="#2C2C2C"
|
||||
VerticalOptions="Start"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
Padding="20"
|
||||
BorderColor="Transparent">
|
||||
<VerticalStackLayout>
|
||||
<Label Text="{Binding ProgressText}"
|
||||
TextColor="White"
|
||||
FontSize="12"
|
||||
HorizontalTextAlignment="Center" />
|
||||
<!--Back Button-->
|
||||
<Button Text="← Back to Library"
|
||||
BackgroundColor="#D32F2F"
|
||||
TextColor="White"
|
||||
FontSize="14"
|
||||
CornerRadius="8"
|
||||
HeightRequest="45"
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto"
|
||||
Padding="16,24,16,20">
|
||||
<Border Grid.Row="0"
|
||||
BackgroundColor="#F7E8D9"
|
||||
StrokeThickness="0"
|
||||
StrokeShape="RoundRectangle 24"
|
||||
Padding="16">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="12">
|
||||
<Button Grid.Column="0"
|
||||
Text="Назад"
|
||||
Style="{StaticResource SecondaryButtonStyle}"
|
||||
Padding="16,10"
|
||||
Clicked="OnBackToLibrary" />
|
||||
|
||||
<VerticalStackLayout Grid.Column="1"
|
||||
VerticalOptions="Center"
|
||||
Spacing="2">
|
||||
<Label Text="{Binding Book.Title}"
|
||||
FontFamily="OpenSansSemibold"
|
||||
FontSize="17"
|
||||
TextColor="{StaticResource InkColor}"
|
||||
MaxLines="2"
|
||||
LineBreakMode="TailTruncation" />
|
||||
<Label Text="{Binding ProgressText}"
|
||||
FontSize="13"
|
||||
TextColor="{StaticResource AccentDarkColor}" />
|
||||
<Label Text="{Binding ChapterProgressText}"
|
||||
FontSize="12"
|
||||
TextColor="{StaticResource InkSoftColor}"
|
||||
MaxLines="1"
|
||||
LineBreakMode="TailTruncation" />
|
||||
</VerticalStackLayout>
|
||||
</Frame>
|
||||
<!--Нижняя панель-->
|
||||
<Frame VerticalOptions="End"
|
||||
HorizontalOptions="FillAndExpand"
|
||||
BackgroundColor="#2C2C2C"
|
||||
Padding="20"
|
||||
BorderColor="Transparent">
|
||||
<Frame.GestureRecognizers>
|
||||
|
||||
<Button Grid.Column="2"
|
||||
Text="Скрыть"
|
||||
Style="{StaticResource TonalButtonStyle}"
|
||||
Command="{Binding HideMenuCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="2"
|
||||
BackgroundColor="#FFF8F0"
|
||||
StrokeThickness="0"
|
||||
StrokeShape="RoundRectangle 28"
|
||||
Padding="18"
|
||||
VerticalOptions="End">
|
||||
<Border.GestureRecognizers>
|
||||
<TapGestureRecognizer Tapped="OnMenuPanelTapped" />
|
||||
</Frame.GestureRecognizers>
|
||||
</Border.GestureRecognizers>
|
||||
|
||||
<VerticalStackLayout Spacing="15">
|
||||
<!--Title-->
|
||||
<Label Text="Reading Settings"
|
||||
FontSize="18"
|
||||
FontAttributes="Bold"
|
||||
TextColor="White"
|
||||
HorizontalOptions="Center" />
|
||||
<ScrollView HeightRequest="360">
|
||||
<VerticalStackLayout Spacing="16">
|
||||
<Label Text="Параметры чтения"
|
||||
FontFamily="OpenSansSemibold"
|
||||
FontSize="20"
|
||||
TextColor="{StaticResource InkColor}" />
|
||||
|
||||
<!--Font Size-->
|
||||
<VerticalStackLayout Spacing="5">
|
||||
<Label Text="Font Size"
|
||||
FontSize="14"
|
||||
TextColor="#B0B0B0" />
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="10">
|
||||
<Grid ColumnDefinitions="*,*,*"
|
||||
ColumnSpacing="10">
|
||||
<Button Grid.Column="0"
|
||||
Text="Тёплая"
|
||||
Style="{StaticResource TonalButtonStyle}"
|
||||
Clicked="OnSepiaThemeClicked" />
|
||||
<Button Grid.Column="1"
|
||||
Text="Светлая"
|
||||
Style="{StaticResource TonalButtonStyle}"
|
||||
Clicked="OnLightThemeClicked" />
|
||||
<Button Grid.Column="2"
|
||||
Text="Тёмная"
|
||||
Style="{StaticResource TonalButtonStyle}"
|
||||
Clicked="OnDarkThemeClicked" />
|
||||
</Grid>
|
||||
|
||||
<VerticalStackLayout Spacing="8">
|
||||
<Label Text="Размер шрифта"
|
||||
Style="{StaticResource CaptionStyle}" />
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="12">
|
||||
<Button Grid.Column="0"
|
||||
Text="A-"
|
||||
FontSize="14"
|
||||
WidthRequest="45"
|
||||
HeightRequest="40"
|
||||
BackgroundColor="#444"
|
||||
TextColor="White"
|
||||
CornerRadius="8"
|
||||
Style="{StaticResource SecondaryButtonStyle}"
|
||||
WidthRequest="56"
|
||||
Clicked="OnDecreaseFontSize" />
|
||||
<Label Grid.Column="1"
|
||||
Text="{Binding FontSize, StringFormat='{0}px'}"
|
||||
FontSize="16"
|
||||
TextColor="White"
|
||||
Text="{Binding FontSize, StringFormat='Текущее значение: {0}px'}"
|
||||
FontSize="14"
|
||||
TextColor="{StaticResource InkColor}"
|
||||
HorizontalOptions="Center"
|
||||
VerticalOptions="Center" />
|
||||
<Button Grid.Column="2"
|
||||
Text="A+"
|
||||
FontSize="14"
|
||||
WidthRequest="45"
|
||||
HeightRequest="40"
|
||||
BackgroundColor="#444"
|
||||
TextColor="White"
|
||||
CornerRadius="8"
|
||||
Style="{StaticResource SecondaryButtonStyle}"
|
||||
WidthRequest="56"
|
||||
Clicked="OnIncreaseFontSize" />
|
||||
</Grid>
|
||||
</VerticalStackLayout>
|
||||
|
||||
<!--Font Family-->
|
||||
<VerticalStackLayout Spacing="5">
|
||||
<Label Text="Font Family"
|
||||
FontSize="14"
|
||||
TextColor="#B0B0B0" />
|
||||
<VerticalStackLayout Spacing="8">
|
||||
<Label Text="{Binding Brightness, StringFormat='Яркость: {0:F0}%'}"
|
||||
Style="{StaticResource CaptionStyle}" />
|
||||
<Slider Minimum="70"
|
||||
Maximum="120"
|
||||
Value="{Binding Brightness}"
|
||||
ValueChanged="OnBrightnessChanged"
|
||||
Style="{StaticResource ReaderSliderStyle}" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<VerticalStackLayout Spacing="8">
|
||||
<Label Text="Гарнитура"
|
||||
Style="{StaticResource CaptionStyle}" />
|
||||
<Picker x:Name="FontFamilyPicker"
|
||||
ItemsSource="{Binding AvailableFonts}"
|
||||
SelectedItem="{Binding FontFamily}"
|
||||
TextColor="White"
|
||||
BackgroundColor="#444"
|
||||
FontSize="14"
|
||||
Style="{StaticResource AppPickerStyle}"
|
||||
SelectedIndexChanged="OnFontFamilyChanged" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<!--Chapters Button-->
|
||||
<Button Text="📑 Chapters"
|
||||
BackgroundColor="#5D4037"
|
||||
TextColor="White"
|
||||
FontSize="14"
|
||||
CornerRadius="8"
|
||||
HeightRequest="45"
|
||||
<Button Text="Оглавление"
|
||||
Style="{StaticResource PrimaryButtonStyle}"
|
||||
Command="{Binding ToggleChapterListCommand}" />
|
||||
|
||||
<!--Chapter List-->
|
||||
<CollectionView ItemsSource="{Binding Chapters}"
|
||||
IsVisible="{Binding IsChapterListVisible}"
|
||||
MaximumHeightRequest="200"
|
||||
MaximumHeightRequest="190"
|
||||
SelectionMode="Single"
|
||||
SelectionChanged="OnChapterSelected">
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<Grid Padding="10,8" BackgroundColor="Transparent">
|
||||
<Border BackgroundColor="{StaticResource SurfaceMuted}"
|
||||
Stroke="{StaticResource BorderColor}"
|
||||
StrokeThickness="1"
|
||||
StrokeShape="RoundRectangle 16"
|
||||
Margin="0,0,0,10"
|
||||
Padding="12,10">
|
||||
<Label Text="{Binding .}"
|
||||
TextColor="#E0E0E0"
|
||||
FontSize="13"
|
||||
TextColor="{StaticResource InkColor}"
|
||||
LineBreakMode="TailTruncation" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
</CollectionView>
|
||||
</VerticalStackLayout>
|
||||
</Frame>
|
||||
</ScrollView>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!-- Нижняя постоянная панель отображает прогресс чтения -->
|
||||
<VerticalStackLayout VerticalOptions="End"
|
||||
HorizontalOptions="Center"
|
||||
Padding="10"
|
||||
Padding="12,0,12,16"
|
||||
InputTransparent="True">
|
||||
|
||||
<Frame BackgroundColor="#AA000000"
|
||||
CornerRadius="15"
|
||||
Padding="10,2"
|
||||
BorderColor="Transparent"
|
||||
HasShadow="False">
|
||||
<Border BackgroundColor="#B31F1612"
|
||||
StrokeThickness="0"
|
||||
StrokeShape="RoundRectangle 18"
|
||||
Padding="12,6">
|
||||
<Label Text="{Binding ProgressText}"
|
||||
TextColor="White"
|
||||
FontSize="12"
|
||||
HorizontalTextAlignment="Center" />
|
||||
</Frame>
|
||||
</Border>
|
||||
</VerticalStackLayout>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
@@ -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<JObject> _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() ?? "";
|
||||
|
||||
// Отправляем данные чанками чтобы не превысить лимит JS строки
|
||||
const int chunkSize = 400_000;
|
||||
|
||||
if (base64.Length > chunkSize)
|
||||
{
|
||||
var chunks = SplitString(base64, chunkSize);
|
||||
var lastCfi = _viewModel.GetLastCfi() ?? string.Empty;
|
||||
var locations = _viewModel.GetLocations() ?? string.Empty;
|
||||
|
||||
await EvalJsAsync("window._bkChunks = [];");
|
||||
|
||||
for (int i = 0; i < chunks.Count; i++)
|
||||
using (var fileStream = File.OpenRead(book.FilePath))
|
||||
{
|
||||
await EvalJsAsync($"window._bkChunks.push('{chunks[i]}');");
|
||||
var buffer = new byte[Constants.Reader.Base64RawChunkSize];
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = await fileStream.ReadAsync(buffer.AsMemory(0, buffer.Length))) > 0)
|
||||
{
|
||||
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)}');"
|
||||
);
|
||||
$"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)}');"
|
||||
);
|
||||
}
|
||||
|
||||
_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<double>() ?? 0;
|
||||
var cfi = data["cfi"]?.ToString();
|
||||
var currentPage = data["currentPage"]?.Value<int>() ?? 0;
|
||||
var totalPages = data["totalPages"]?.Value<int>() ?? 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<int>() ?? 0;
|
||||
var totalPages = data["totalPages"]?.Value<int>() ?? 0;
|
||||
|
||||
// Ловим новые данные по главе
|
||||
var chapterPage = data["chapterCurrentPage"]?.Value<int>() ?? 1;
|
||||
var chapterTotal = data["chapterTotalPages"]?.Value<int>() ?? 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<List<JObject>>() ?? new();
|
||||
var chapters = data["chapters"]?.ToObject<List<JObject>>() ?? new List<JObject>();
|
||||
_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 () =>
|
||||
{
|
||||
await EvalJsAsync($"window.setFontSize({_viewModel.FontSize})");
|
||||
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();
|
||||
// Сохраняем в базу данных
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет JavaScript без ожидания результата
|
||||
/// </summary>
|
||||
private async Task EvalJsAsync(string script)
|
||||
{
|
||||
try
|
||||
@@ -335,9 +344,6 @@ public partial class ReaderPage : ContentPage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Выполняет JavaScript и возвращает результат
|
||||
/// </summary>
|
||||
private async Task<string?> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Разбивает строку на чанки заданного размера
|
||||
/// </summary>
|
||||
private static List<string> SplitString(string str, int chunkSize)
|
||||
{
|
||||
var chunks = new List<string>();
|
||||
for (int i = 0; i < str.Length; i += chunkSize)
|
||||
{
|
||||
chunks.Add(str.Substring(i, Math.Min(chunkSize, str.Length - i)));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Экранирует строку для вставки в JS код (внутри одинарных кавычек)
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Убирает экранирование из результата EvaluateJavaScriptAsync.
|
||||
/// Android WebView оборачивает результат в кавычки и экранирует.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,120 +1,130 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
xmlns:vm="clr-namespace:BookReader.ViewModels"
|
||||
x:Class="BookReader.Views.SettingsPage"
|
||||
x:DataType="vm:SettingsViewModel"
|
||||
Title="{Binding Title}"
|
||||
BackgroundColor="#1E1E1E">
|
||||
Title="Настройки"
|
||||
Background="{StaticResource AppBackgroundBrush}">
|
||||
|
||||
<ScrollView>
|
||||
<VerticalStackLayout Spacing="20" Padding="20">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<VerticalStackLayout Grid.Row="0"
|
||||
Padding="20,24,20,14"
|
||||
Spacing="6">
|
||||
<Label Text="Настройки"
|
||||
Style="{StaticResource PageTitleStyle}" />
|
||||
<Label Text="Подключите Calibre и задайте параметры чтения, которые будут применяться ко всем новым книгам."
|
||||
Style="{StaticResource PageSubtitleStyle}" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<!-- Calibre-Web Settings -->
|
||||
<Frame BackgroundColor="#2C2C2C"
|
||||
CornerRadius="12"
|
||||
Padding="15"
|
||||
HasShadow="True"
|
||||
BorderColor="Transparent">
|
||||
<VerticalStackLayout Spacing="12">
|
||||
<Label Text="☁️ Calibre-Web Connection"
|
||||
FontSize="18"
|
||||
FontAttributes="Bold"
|
||||
TextColor="White" />
|
||||
<ScrollView Grid.Row="1"
|
||||
Margin="0,0,0,24">
|
||||
<VerticalStackLayout Padding="20,0,20,0"
|
||||
Spacing="16">
|
||||
<Border Style="{StaticResource CardBorderStyle}">
|
||||
<VerticalStackLayout Spacing="14">
|
||||
<Label Text="Подключение к Calibre"
|
||||
Style="{StaticResource SectionTitleStyle}" />
|
||||
|
||||
<VerticalStackLayout Spacing="5">
|
||||
<Label Text="Server URL"
|
||||
FontSize="12"
|
||||
TextColor="#B0B0B0" />
|
||||
<VerticalStackLayout Spacing="6">
|
||||
<Label Text="Адрес сервера"
|
||||
Style="{StaticResource CaptionStyle}" />
|
||||
<Entry Text="{Binding CalibreUrl}"
|
||||
Placeholder="https://your-calibre-server.com"
|
||||
PlaceholderColor="#666"
|
||||
TextColor="White"
|
||||
BackgroundColor="#3C3C3C"
|
||||
Placeholder="https://your-calibre-server.example"
|
||||
Style="{StaticResource AppEntryStyle}"
|
||||
Keyboard="Url" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<VerticalStackLayout Spacing="5">
|
||||
<Label Text="Username"
|
||||
FontSize="12"
|
||||
TextColor="#B0B0B0" />
|
||||
<VerticalStackLayout Spacing="6">
|
||||
<Label Text="Логин"
|
||||
Style="{StaticResource CaptionStyle}" />
|
||||
<Entry Text="{Binding CalibreUsername}"
|
||||
Placeholder="Username"
|
||||
PlaceholderColor="#666"
|
||||
TextColor="White"
|
||||
BackgroundColor="#3C3C3C" />
|
||||
Placeholder="Имя пользователя"
|
||||
Style="{StaticResource AppEntryStyle}" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<VerticalStackLayout Spacing="5">
|
||||
<Label Text="Password"
|
||||
FontSize="12"
|
||||
TextColor="#B0B0B0" />
|
||||
<VerticalStackLayout Spacing="6">
|
||||
<Label Text="Пароль"
|
||||
Style="{StaticResource CaptionStyle}" />
|
||||
<Entry Text="{Binding CalibrePassword}"
|
||||
Placeholder="Password"
|
||||
PlaceholderColor="#666"
|
||||
TextColor="White"
|
||||
BackgroundColor="#3C3C3C"
|
||||
Placeholder="Пароль"
|
||||
Style="{StaticResource AppEntryStyle}"
|
||||
IsPassword="True" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<Button Text="Test Connection"
|
||||
BackgroundColor="#5D4037"
|
||||
TextColor="White"
|
||||
CornerRadius="8"
|
||||
<Border Style="{StaticResource MutedCardBorderStyle}">
|
||||
<Label Text="{Binding ConnectionSecurityHint}"
|
||||
TextColor="{Binding ConnectionSecurityColor}"
|
||||
FontSize="13"
|
||||
LineBreakMode="WordWrap" />
|
||||
</Border>
|
||||
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
|
||||
<Button Grid.Column="0"
|
||||
Text="Проверить"
|
||||
Style="{StaticResource SecondaryButtonStyle}"
|
||||
Command="{Binding TestConnectionCommand}"
|
||||
IsEnabled="{Binding IsConnectionTesting, Converter={StaticResource InvertedBoolConverter}}" />
|
||||
<Button Grid.Column="1"
|
||||
Text="Сохранить"
|
||||
Style="{StaticResource PrimaryButtonStyle}"
|
||||
Command="{Binding SaveSettingsCommand}" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
IsVisible="{Binding ConnectionStatus, Converter={StaticResource IsNotNullOrEmptyConverter}}">
|
||||
<Label Text="{Binding ConnectionStatus}"
|
||||
TextColor="{Binding ConnectionStatusColor}"
|
||||
FontSize="13"
|
||||
TextColor="#81C784"
|
||||
IsVisible="{Binding ConnectionStatus, Converter={StaticResource IsNotNullOrEmptyConverter}}" />
|
||||
VerticalOptions="Center" />
|
||||
<ActivityIndicator Grid.Column="1"
|
||||
IsRunning="{Binding IsConnectionTesting}"
|
||||
IsVisible="{Binding IsConnectionTesting}"
|
||||
Color="{StaticResource AccentColor}" />
|
||||
</Grid>
|
||||
</VerticalStackLayout>
|
||||
</Frame>
|
||||
</Border>
|
||||
|
||||
<!-- Reading Settings -->
|
||||
<Frame BackgroundColor="#2C2C2C"
|
||||
CornerRadius="12"
|
||||
Padding="15"
|
||||
HasShadow="True"
|
||||
BorderColor="Transparent">
|
||||
<VerticalStackLayout Spacing="12">
|
||||
<Label Text="📖 Reading Defaults"
|
||||
FontSize="18"
|
||||
FontAttributes="Bold"
|
||||
TextColor="White" />
|
||||
<Border Style="{StaticResource CardBorderStyle}">
|
||||
<VerticalStackLayout Spacing="14">
|
||||
<Label Text="Чтение по умолчанию"
|
||||
Style="{StaticResource SectionTitleStyle}" />
|
||||
|
||||
<VerticalStackLayout Spacing="5">
|
||||
<Label Text="Default Font Size"
|
||||
FontSize="12"
|
||||
TextColor="#B0B0B0" />
|
||||
<VerticalStackLayout Spacing="6">
|
||||
<Label Text="Размер шрифта"
|
||||
Style="{StaticResource CaptionStyle}" />
|
||||
<Picker ItemsSource="{Binding AvailableFontSizes}"
|
||||
SelectedItem="{Binding DefaultFontSize}"
|
||||
TextColor="White"
|
||||
BackgroundColor="#3C3C3C" />
|
||||
Style="{StaticResource AppPickerStyle}" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<VerticalStackLayout Spacing="5">
|
||||
<Label Text="Default Font Family"
|
||||
FontSize="12"
|
||||
TextColor="#B0B0B0" />
|
||||
<VerticalStackLayout Spacing="6">
|
||||
<Label Text="Гарнитура"
|
||||
Style="{StaticResource CaptionStyle}" />
|
||||
<Picker ItemsSource="{Binding AvailableFonts}"
|
||||
SelectedItem="{Binding DefaultFontFamily}"
|
||||
TextColor="White"
|
||||
BackgroundColor="#3C3C3C" />
|
||||
Style="{StaticResource AppPickerStyle}" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<VerticalStackLayout Spacing="6">
|
||||
<Label Text="Тема ридера"
|
||||
Style="{StaticResource CaptionStyle}" />
|
||||
<Picker ItemsSource="{Binding AvailableReaderThemes}"
|
||||
SelectedItem="{Binding DefaultReaderTheme}"
|
||||
Style="{StaticResource AppPickerStyle}" />
|
||||
</VerticalStackLayout>
|
||||
|
||||
<VerticalStackLayout Spacing="8">
|
||||
<Label Text="{Binding DefaultBrightness, StringFormat='Яркость страницы: {0:F0}%'}"
|
||||
Style="{StaticResource CaptionStyle}" />
|
||||
<Slider Minimum="70"
|
||||
Maximum="120"
|
||||
Value="{Binding DefaultBrightness}"
|
||||
Style="{StaticResource ReaderSliderStyle}" />
|
||||
</VerticalStackLayout>
|
||||
</VerticalStackLayout>
|
||||
</Frame>
|
||||
|
||||
<!-- Save Button -->
|
||||
<Button Text="💾 Save Settings"
|
||||
BackgroundColor="#4CAF50"
|
||||
TextColor="White"
|
||||
FontSize="16"
|
||||
FontAttributes="Bold"
|
||||
CornerRadius="12"
|
||||
HeightRequest="50"
|
||||
Command="{Binding SaveSettingsCommand}" />
|
||||
|
||||
</Border>
|
||||
</VerticalStackLayout>
|
||||
</ScrollView>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
@@ -1,4 +1,4 @@
|
||||
using BookReader.ViewModels;
|
||||
using BookReader.ViewModels;
|
||||
|
||||
namespace BookReader.Views;
|
||||
|
||||
@@ -31,10 +31,9 @@ public partial class SettingsPage : ContentPage
|
||||
{
|
||||
base.OnDisappearing();
|
||||
|
||||
// Автосохранение при выходе со страницы настроек
|
||||
try
|
||||
{
|
||||
await _viewModel.SaveSettingsCommand.ExecuteAsync(null);
|
||||
await _viewModel.SaveSilentlyAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user