begon to merge to bloc

This commit is contained in:
mdaniel3
2025-06-11 23:34:34 +02:00
parent 82be5ac01d
commit 445875781b
166 changed files with 11841 additions and 535 deletions
-15
View File
@@ -15,24 +15,9 @@ migration:
- platform: root
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: android
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: ios
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: linux
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: macos
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: web
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
- platform: windows
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1
# User provided section
+25
View File
@@ -0,0 +1,25 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "calibre-web-companion",
"request": "launch",
"type": "dart"
},
{
"name": "calibre-web-companion (profile mode)",
"request": "launch",
"type": "dart",
"flutterMode": "profile"
},
{
"name": "calibre-web-companion (release mode)",
"request": "launch",
"type": "dart",
"flutterMode": "release"
}
]
}
+9
View File
@@ -0,0 +1,9 @@
class AuthException implements Exception {
final String message;
final int? statusCode;
AuthException(this.message, {this.statusCode});
@override
String toString() => message;
}
+735
View File
@@ -0,0 +1,735 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:calibre_web_companion/features/book_view/data/datasources/book_view_datasource.dart';
import 'package:http/http.dart' as http;
import 'package:logger/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:xml2json/xml2json.dart';
import 'package:html/parser.dart' as parser;
import 'package:http_parser/http_parser.dart' show MediaType;
/// Authentication methods supported by the API
enum AuthMethod { none, cookie, basic }
/// Authentication systems for proxies
enum AuthSystem {
none,
authelia,
cloudflareZeroTrust,
swag,
traefik,
nginxProxy,
custom,
}
/// Service class to handle API requests with various authentication methods
class ApiService {
final Logger _logger = Logger();
final http.Client _client = http.Client();
String? _baseUrl;
String? _cookie;
String? _username;
String? _password;
String? _basePath;
AuthSystem _authSystem = AuthSystem.none;
static final ApiService _instance = ApiService._internal();
factory ApiService() => _instance;
ApiService._internal();
/// Returns the base URL or an empty string
String getBaseUrl() {
return _baseUrl ?? '';
}
/// Returns the username or an empty string
String getUsername() {
return _username ?? '';
}
/// Returns the password or an empty string
String getPassword() {
return _password ?? '';
}
/// Initializes the API service with credentials from shared preferences
Future<void> initialize() async {
final prefs = await SharedPreferences.getInstance();
_baseUrl = prefs.getString('base_url');
_cookie = prefs.getString('calibre_web_session');
_username = prefs.getString('username');
_password = prefs.getString('password');
_basePath = prefs.getString('base_path') ?? '';
final authSystemString = prefs.getString('auth_system') ?? 'none';
try {
_authSystem = AuthSystem.values.firstWhere(
(e) => e.toString().split('.').last == authSystemString,
orElse: () => AuthSystem.none,
);
} catch (e) {
_authSystem = AuthSystem.none;
}
_logger.i('Initialized API service with auth system: $_authSystem');
}
void dispose() {
_client.close();
}
/// Makes an authenticated GET request
/// Returns the parsed JSON response or throws an exception
///
/// Parameters:
///
/// - `endpoint`: The API endpoint to request
/// - `authMethod`: The authentication method to use
/// - `queryParams`: Optional query parameters
Future<Map<String, dynamic>> getJson(
String endpoint,
AuthMethod authMethod, {
Map<String, String>? queryParams,
}) async {
final response = await get(endpoint, authMethod, queryParams: queryParams);
try {
if (response.body.length > 50) {
_logger.d('Response body: ${response.body.substring(0, 50)}...');
} else {
_logger.d('Response body: ${response.body}');
}
return json.decode(response.body) as Map<String, dynamic>;
} catch (e) {
_logger.e('Failed to parse JSON response: $e');
_logger.d('Response body: ${response.body}...');
throw FormatException('Invalid JSON response: $e');
}
}
/// Makes an authenticated GET request and converts XML response to JSON using Parker format
/// Returns the parsed JSON response or throws an exception
///
/// Parameters:
///
/// - `endpoint`: The API endpoint to request
/// - `authMethod`: The authentication method to use
/// - `queryParams`: Optional query parameters
Future<Map<String, dynamic>> getXmlAsJson(
String endpoint,
AuthMethod authMethod, {
Map<String, String>? queryParams,
}) async {
final transformer = Xml2Json();
final response = await get(endpoint, authMethod, queryParams: queryParams);
try {
if (response.body.length > 50) {
_logger.d('Response body: ${response.body.substring(0, 50)}...');
} else {
_logger.d('Response body: ${response.body}');
}
transformer.parse(response.body);
String jsonString = transformer.toParkerWithAttrs();
return json.decode(jsonString) as Map<String, dynamic>;
} catch (e) {
_logger.e('Failed to parse JSON response: $e');
_logger.d('Response body: ${response.body}...');
throw FormatException('Invalid JSON response: $e');
}
}
/// Makes an authenticated GET request
/// Returns the raw response object
///
/// Parameters:
///
/// - `endpoint`: The API endpoint to request
/// - `authMethod`: The authentication method to use
/// - `queryParams`: Optional query parameters
Future<http.Response> get(
String endpoint,
AuthMethod authMethod, {
Map<String, String>? queryParams,
}) async {
await _ensureInitialized();
final uri = _buildUri(endpoint, queryParams);
final headers = _getAuthHeaders(authMethod);
// Add processed custom headers for auth system
final customHeaders = await _processCustomHeaders();
headers.addAll(customHeaders);
_logger.d('GET request to: $uri');
_logger.d(
'Using ${authMethod.name} authentication with ${_authSystem.name} proxy system',
);
_logger.d('Headers: $headers');
try {
final response = await _client.get(uri, headers: headers);
_logger.i('Response status: ${response.statusCode}');
_checkResponseStatus(response.statusCode);
return response;
} catch (e) {
_logger.e('Request failed: $e');
rethrow;
}
}
/// Makes an authenticated POST request with optional CSRF token
///
/// Parameters:
///
/// - `endpoint`: The API endpoint to request
/// - `queryParams`: Optional query parameters
/// - `body`: The request body
/// - `authMethod`: The authentication method to use
/// - `contentType`: The content type of the request
/// - `useCsrf`: Whether to fetch and include CSRF token
/// - `csrfSelector`: CSS selector for the CSRF token input field
/// Makes an authenticated POST request with optional CSRF token
///
/// Parameters:
///
/// - `endpoint`: The API endpoint to request
/// - `queryParams`: Optional query parameters
/// - `body`: The request body
/// - `authMethod`: The authentication method to use
/// - `contentType`: The content type of the request
/// - `useCsrf`: Whether to fetch and include CSRF token
/// - `csrfSelector`: CSS selector for the CSRF token input field
Future<http.Response> post(
String endpoint,
Map<String, String>? queryParams,
dynamic body,
AuthMethod authMethod, {
String contentType = 'application/json',
bool useCsrf = false,
String csrfSelector = 'input[name="csrf_token"]',
}) async {
await _ensureInitialized();
final uri = _buildUri(endpoint, queryParams);
// Add processed custom headers for auth system
final customHeaders = await _processCustomHeaders();
// If we need to handle CSRF protection, use a two-step process
if (useCsrf) {
_logger.i('Making CSRF-protected POST request to: $uri');
// STEP 1: Make initial GET request to fetch CSRF token
final getHeaders = _getAuthHeaders(authMethod);
getHeaders.addAll(customHeaders);
getHeaders['Accept'] = 'text/html,application/xhtml+xml,application/xml';
final getResponse = await _client.get(uri, headers: getHeaders);
_logger.d(
'GET response status for CSRF fetch: ${getResponse.statusCode}',
);
if (getResponse.statusCode != 200) {
_logger.e(
'Initial GET request for CSRF token failed: ${getResponse.statusCode}',
);
throw Exception(
'Failed to fetch CSRF token: ${getResponse.statusCode}',
);
}
// Extract CSRF token from HTML
final document = parser.parse(getResponse.body);
final csrfElement = document.querySelector(csrfSelector);
final csrfToken = csrfElement?.attributes['value'];
if (csrfToken == null) {
_logger.e('Could not find CSRF token using selector: $csrfSelector');
throw Exception('CSRF token not found');
}
// Extract new session cookie if available
String sessionCookie = _cookie ?? '';
if (getResponse.headers.containsKey('set-cookie')) {
final setCookieHeader = getResponse.headers['set-cookie']!;
final sessionMatch = RegExp(
r'session=([^;]+)',
).firstMatch(setCookieHeader);
if (sessionMatch != null && sessionMatch.groupCount >= 1) {
sessionCookie = 'session=${sessionMatch.group(1)}';
}
}
// STEP 2: Make POST request with extracted CSRF token
final postHeaders = {
'Content-Type': contentType,
'Cookie': sessionCookie,
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
'Referer': uri.toString(),
'Origin':
'${uri.scheme}://${uri.host}${uri.port != 80 && uri.port != 443 ? ":${uri.port}" : ""}',
};
postHeaders.addAll(customHeaders);
// Add CSRF token to body if it's a map
Map<String, dynamic> finalBody;
if (body is Map) {
if (body is Map<String, dynamic>) {
finalBody = Map<String, dynamic>.from(body);
} else {
finalBody = Map<String, dynamic>.from(
body.map((key, value) => MapEntry(key.toString(), value)),
);
}
finalBody['csrf_token'] = csrfToken;
} else {
finalBody = {'csrf_token': csrfToken};
}
final encodedBody = _encodeBody(finalBody, contentType);
_logger.d('CSRF-protected POST headers: $postHeaders');
_logger.d('CSRF-protected POST body: $encodedBody');
try {
final response = await _client.post(
uri,
headers: postHeaders,
body: encodedBody,
);
_logger.i(
'CSRF-protected POST response status: ${response.statusCode}',
);
_checkResponseStatus(response.statusCode);
return response;
} catch (e) {
_logger.e('CSRF-protected POST request failed: $e');
rethrow;
}
} else {
// Standard POST request without CSRF protection
final headers = _getAuthHeaders(authMethod);
headers['Content-Type'] = contentType;
headers.addAll(customHeaders);
_logger.d('POST request to: $uri');
_logger.d(
'Using ${authMethod.name} authentication with ${_authSystem.name} proxy system',
);
_logger.d('Headers: $headers');
final encodedBody = _encodeBody(body, contentType);
try {
final response = await _client.post(
uri,
headers: headers,
body: encodedBody ?? "",
);
_logger.i('POST response status: ${response.statusCode}');
_checkResponseStatus(response.statusCode);
return response;
} catch (e) {
_logger.e('POST request failed: $e');
rethrow;
}
}
}
/// Makes an authenticated GET request and returns a StreamedResponse
/// This is useful for downloading files or streaming large responses
///
/// Parameters:
///
/// - `endpoint`: The API endpoint to request
/// - `authMethod`: The authentication method to use
Future<http.StreamedResponse> getStream(
String endpoint,
AuthMethod authMethod,
) async {
await _ensureInitialized();
// Ensure URL is complete with base path
String fullPath = endpoint;
if (_basePath != null &&
_basePath!.isNotEmpty &&
!endpoint.startsWith('http')) {
if (endpoint.startsWith('/')) {
fullPath = '/$_basePath$endpoint';
} else {
fullPath = '/$_basePath/$endpoint';
}
}
final fullUrl =
endpoint.startsWith('http') ? endpoint : '$_baseUrl$fullPath';
final headers = _getAuthHeaders(authMethod);
// Add processed custom headers for auth system
final customHeaders = await _processCustomHeaders();
headers.addAll(customHeaders);
_logger.d('GET stream request to: $fullUrl');
_logger.d(
'Using ${authMethod.name} authentication with ${_authSystem.name} proxy system',
);
_logger.d('Headers: $headers');
final request = http.Request('GET', Uri.parse(fullUrl));
request.headers.addAll(headers);
try {
final response = await _client.send(request);
_logger.i('Stream response status: ${response.statusCode}');
_checkResponseStatus(response.statusCode);
return response;
} catch (e) {
_logger.e('Stream request failed: $e');
rethrow;
}
}
/// Fetches a CSRF token from the specified endpoint
///
/// Parameters:
///
/// - `endpoint`: The endpoint to fetch the token from
/// - `authMethod`: The authentication method to use
/// - `selector`: CSS selector for the CSRF token input
Future<Map<String, String?>> fetchCsrfToken(
String endpoint,
AuthMethod authMethod,
String selector,
) async {
_logger.d('Fetching CSRF token from: $endpoint');
final response = await get(endpoint, authMethod);
final document = parser.parse(response.body);
final csrfElement = document.querySelector(selector);
final csrfToken = csrfElement?.attributes['value'];
if (csrfToken == null) {
_logger.w(
'CSRF token not found in the response using selector: $selector',
);
} else {
_logger.d('CSRF token found: $csrfToken');
}
return {'token': csrfToken, 'cookies': response.headers['set-cookie']};
}
/// Ensures credentials are loaded before making requests
Future<void> _ensureInitialized() async {
if (_baseUrl == null) {
await initialize();
if (_baseUrl == null) {
throw Exception(
'Server URL is missing. Please configure the app settings.',
);
}
}
}
/// Builds a URI for API requests
///
/// Parameters:
///
/// - `endpoint`: The API endpoint to request
Uri _buildUri(String endpoint, Map<String, String>? queryParams) {
// Handle base path if set
String fullPath = endpoint;
if (_basePath != null && _basePath!.isNotEmpty) {
// Make sure we don't duplicate slashes
if (endpoint.startsWith('/')) {
fullPath = '/$_basePath$endpoint';
} else {
fullPath = '/$_basePath/$endpoint';
}
}
return Uri.parse(
'$_baseUrl$fullPath',
).replace(queryParameters: queryParams);
}
/// Process custom headers, replacing placeholders with actual values
Future<Map<String, String>> _processCustomHeaders() async {
final prefs = await SharedPreferences.getInstance();
final headersJson = prefs.getString('custom_login_headers') ?? '[]';
final List<dynamic> decodedList = jsonDecode(headersJson);
final List<Map<String, String>> customHeaders =
decodedList
.map((item) => Map<String, String>.from(item as Map))
.toList();
Map<String, String> processedHeaders = {};
for (var header in customHeaders) {
String key = header.keys.first;
String value = header.values.first;
// Replace username placeholder if available
if (value.contains('\${USERNAME}') && _username != null) {
value = value.replaceAll('\${USERNAME}', _username!);
}
processedHeaders[key] = value;
}
return processedHeaders;
}
/// Gets authentication headers based on the auth method
///
/// Parameters:
///
/// - `authMethod`: The authentication method to use
Map<String, String> _getAuthHeaders(AuthMethod authMethod) {
Map<String, String> headers = {};
if (authMethod == AuthMethod.cookie && _cookie != null) {
headers['Cookie'] = _cookie!;
} else if (_username != null && _password != null) {
headers['Authorization'] =
'Basic ${base64.encode(utf8.encode('$_username:$_password'))}';
}
return headers;
}
/// Encodes request body based on content type
///
/// Parameters:
///
/// - `body`: The request body to encode
/// - `contentType`: The content type of the request
dynamic _encodeBody(dynamic body, String contentType) {
if (body is Map) {
if (contentType == 'application/json') {
return json.encode(body);
} else if (contentType == 'application/x-www-form-urlencoded') {
// Convert map to URL encoded string format key1=value1&key2=value2
return body.entries
.map(
(e) =>
'${Uri.encodeComponent(e.key.toString())}=${Uri.encodeComponent(e.value.toString())}',
)
.join('&');
}
}
return body;
}
/// Checks response status code and throws appropriate exceptions
///
/// Parameters:
///
/// - `statusCode`: The status code to check
void _checkResponseStatus(int statusCode) {
if (statusCode == 401) {
throw Exception('Authentication failed. Please log in again.');
} else if (statusCode >= 500) {
throw Exception('Server error: $statusCode');
} else if (statusCode >= 400) {
throw Exception('Request failed with status $statusCode');
}
}
/// Uploads a file to the specified endpoint with cancellation support
///
/// Parameters:
/// - `file`: The file to upload
/// - `endpoint`: The endpoint to upload to (e.g., '/upload')
/// - `cancelToken`: Optional token to cancel the operation
/// - `formFieldName`: The name of the form field for the file
/// - `additionalFields`: Additional form fields to include
/// - `timeoutSeconds`: Timeout in seconds
///
/// Returns a map with upload result information
Future<Map<String, dynamic>> uploadFile(
File file,
String endpoint, {
CancellationToken? cancelToken,
String formFieldName = 'btn-upload',
Map<String, String> additionalFields = const {'btn-upload2': ''},
int timeoutSeconds = 60,
AuthMethod authMethod = AuthMethod.cookie,
}) async {
await _ensureInitialized();
_logger.i('Starting upload of file: ${file.path.split('/').last}');
// Check for cancellation before starting
if (cancelToken?.isCancelled == true) {
_logger.i('Upload cancelled before starting');
return {'success': false, 'cancelled': true};
}
// Get CSRF token
final csrfResult = await fetchCsrfToken(
'/',
authMethod,
'input[name="csrf_token"]',
);
// Check for cancellation after token fetch
if (cancelToken?.isCancelled == true) {
_logger.i('Upload cancelled after CSRF token fetch');
return {'success': false, 'cancelled': true};
}
final csrfToken = csrfResult['token'];
if (csrfToken == null) {
throw Exception('Failed to get CSRF token for upload');
}
// Prepare upload request
final uri = _buildUri(endpoint, null);
final request = http.MultipartRequest('POST', uri);
// Add authentication cookies
request.headers['Cookie'] = csrfResult['cookies'] ?? '';
// Add CSRF token
request.fields['csrf_token'] = csrfToken;
// Add any additional fields
additionalFields.forEach((key, value) {
request.fields[key] = value;
});
// Add custom headers for auth system
final customHeaders = await _processCustomHeaders();
request.headers.addAll(customHeaders);
// Add the file
final fileName = file.path.split('/').last;
final fileExtension = fileName.split('.').last.toLowerCase();
// Determine content type based on file extension
String contentType = 'application/octet-stream';
if (fileExtension == 'epub') {
contentType = 'application/epub+zip';
} else if (fileExtension == 'pdf') {
contentType = 'application/pdf';
} else if (fileExtension == 'mobi') {
contentType = 'application/x-mobipocket-ebook';
}
// Check for cancellation before file preparation
if (cancelToken?.isCancelled == true) {
_logger.i('Upload cancelled before file preparation');
return {'success': false, 'cancelled': true};
}
request.files.add(
await http.MultipartFile.fromPath(
formFieldName,
file.path,
filename: fileName,
contentType: MediaType.parse(contentType),
),
);
// Send request
final client = http.Client();
try {
// Check for cancellation before sending request
if (cancelToken?.isCancelled == true) {
_logger.i('Upload cancelled before sending request');
client.close();
return {'success': false, 'cancelled': true};
}
// Create completer to allow cancellation during request
final completer = Completer<http.StreamedResponse>();
// Start the request
final futureResponse = client.send(request);
// Complete with the response when it arrives
futureResponse
.then((value) {
if (!completer.isCompleted) {
completer.complete(value);
}
})
.catchError((error) {
if (!completer.isCompleted) {
completer.completeError(error);
}
});
// Set up a cancellation listener
if (cancelToken != null) {
// Check periodically if cancellation is requested
Timer.periodic(Duration(milliseconds: 100), (timer) {
if (cancelToken.isCancelled && !completer.isCompleted) {
timer.cancel();
completer.completeError(Exception('Operation cancelled'));
client.close();
}
// Stop timer if completer is already completed
if (completer.isCompleted) {
timer.cancel();
}
});
}
// Wait for the response with timeout
final streamedResponse = await completer.future.timeout(
Duration(seconds: timeoutSeconds),
onTimeout: () {
_logger.e('Upload request timed out');
throw TimeoutException('Upload request timed out');
},
);
// Check for cancellation after receiving response
if (cancelToken?.isCancelled == true) {
_logger.i('Upload cancelled after receiving response');
return {'success': false, 'cancelled': true};
}
final response = await http.Response.fromStream(streamedResponse);
_logger.i('Upload response status: ${response.statusCode}');
if (response.statusCode == 200 || response.statusCode == 302) {
_logger.i('File uploaded successfully: $fileName');
return {
'success': true,
'statusCode': response.statusCode,
'response': response,
};
} else {
_logger.e('Failed to upload file: Status ${response.statusCode}');
return {
'success': false,
'statusCode': response.statusCode,
'response': response,
'error': 'Upload failed with status ${response.statusCode}',
};
}
} catch (e) {
if (cancelToken?.isCancelled == true) {
_logger.i('Upload was cancelled: $e');
return {'success': false, 'cancelled': true};
}
_logger.e('Error uploading file: $e');
return {'success': false, 'error': 'Upload error: $e'};
} finally {
client.close();
}
}
}
+407
View File
@@ -0,0 +1,407 @@
import 'dart:convert';
import 'package:calibre_web_companion/features/book_view/data/models/book_view_model.dart';
import 'api_service.dart';
import 'package:logger/web.dart';
class JsonService {
final ApiService _apiService = ApiService();
Logger logger = Logger();
// Add this to JsonService class
ApiService getApiService() {
return _apiService;
}
// /// Loads a specific book item by its UUID
// ///
// /// Parameters:
// ///
// /// - `bookUuid`: The UUID of the book to fetch
// Future<BookItem> fetchBook({required String bookUuid}) async {
// logger.i('Fetching book - UUID: $bookUuid');
// try {
// final response = await _apiService.get(
// '/ajax/book/$bookUuid',
// AuthMethod.basic,
// );
// // logger.d(response.body);
// if (response.statusCode == 200) {
// BookItem book;
// try {
// // Try parsing the JSON response
// final bookJson = json.decode(response.body);
// book = _parseBookFromJson(bookJson)!;
// } catch (jsonError) {
// // JSON parsing failed, use manual extraction
// logger.w('JSON parsing failed: $jsonError. Using manual extraction.');
// final bookData = _extractBookData(response.body, bookUuid);
// book = _parseBookFromJson(bookData)!;
// }
// // Serieninformationen hinzufügen, wenn nötig
// book = await enhanceBookWithSeriesInfo(book);
// if (book.categories.isNotEmpty) {
// final categoryMap = await fetchCategoryMappings();
// book = await enhanceBookWithCategoryIds(book, categoryMap);
// }
// return book;
// } else {
// throw Exception('Server error: ${response.statusCode}');
// }
// } catch (e) {
// logger.e('Exception while fetching book: $e');
// rethrow;
// }
// }
// /// Extracts book data from a raw JSON response
// ///
// /// Parameters:
// ///
// /// - `responseBody`: The raw JSON response body
// /// - `bookUuid`: The UUID of the book
// Map<String, dynamic> _extractBookData(String responseBody, String bookUuid) {
// Map<String, dynamic> result = {'uuid': bookUuid, 'title': 'Unknown Title'};
// try {
// // Extract ID
// final idMatch = RegExp(
// r'"application_id":\s*(\d+)',
// ).firstMatch(responseBody);
// if (idMatch != null) {
// result['id'] = idMatch.group(1);
// }
// // Extract title (with proper unescaping)
// final titleMatch = RegExp(
// r'"title":\s*"(.*?)(?<!\\)"(?=,|\s*}|\s*")',
// dotAll: true,
// ).firstMatch(responseBody);
// if (titleMatch != null) {
// String title = titleMatch.group(1)!;
// title = title.replaceAll('"', '');
// result['title'] = title;
// }
// // Extract author
// final authorsSection = _extractSection(responseBody, 'authors');
// if (authorsSection != null) {
// final authors = _extractStringArray(authorsSection);
// if (authors.isNotEmpty) {
// result['author'] = authors.join(', ');
// }
// }
// // Extract categories (tags)
// final tagsSection = _extractSection(responseBody, 'tags');
// if (tagsSection != null) {
// final tags = _extractStringArray(tagsSection);
// if (tags.isNotEmpty) {
// result['tags'] = tags;
// }
// }
// // Extract rating
// final ratingMatch = RegExp(
// r'"rating":\s*"([^"]+)"',
// ).firstMatch(responseBody);
// if (ratingMatch != null) {
// result['ratings'] = ratingMatch.group(1);
// }
// // Extract if the book has a cover
// result['has_cover'] = true;
// // Extract series if available
// final seriesMatch = RegExp(
// r'"series":\s*"([^"]+)"',
// ).firstMatch(responseBody);
// if (seriesMatch != null) {
// result['series'] = seriesMatch.group(1);
// } else {
// result['series'] = '';
// }
// // Extract summary
// try {
// final commentsMatch = RegExp(
// r'"comments":\s*"(.*?)(?<!\\)"(?=,|\s*}|\s*")',
// dotAll: true,
// ).firstMatch(responseBody);
// if (commentsMatch != null) {
// String comments = commentsMatch.group(1)!;
// comments = comments.replaceAll(RegExp(r'<[^>]*>'), '');
// result['comments'] = comments;
// } else {
// result['comments'] = '';
// }
// } catch (e) {
// logger.w('Failed to extract comments: $e');
// result['comments'] = '';
// }
// // Extract published date
// final publisherMatch = RegExp(
// r'"publisher":\s*"([^"]+)"',
// ).firstMatch(responseBody);
// if (publisherMatch != null) {
// result['publisher'] = publisherMatch.group(1);
// }
// // Extract language
// final languageMatch = RegExp(
// r'"languages":\s*"([^"]+)"',
// ).firstMatch(responseBody);
// if (languageMatch != null) {
// result['languages'] = languageMatch.group(1);
// }
// final formatsSection = _extractSection(responseBody, 'formats');
// if (formatsSection != null) {
// final formats = _extractStringArray(formatsSection);
// if (formats.isNotEmpty) {
// result['formats'] = formats;
// logger.d('Extracted formats: $formats');
// }
// }
// } catch (e) {
// logger.e('Error during manual data extraction: $e');
// }
// return result;
// }
// /// Extract a section of JSON data based on a field name
// ///
// /// Parameters:
// ///
// /// - `json`: The JSON data to extract from
// /// - `fieldName`: The name of the field to extract
// String? _extractSection(String json, String fieldName) {
// final regex = RegExp('"$fieldName":\\s*(\\[.*?\\])', dotAll: true);
// final match = regex.firstMatch(json);
// return match?.group(1);
// }
// /// Extracts an array of strings from a JSON array
// ///
// /// Parameters:
// ///
// /// - `arrayText`: The JSON array text to extract from
// List<String> _extractStringArray(String arrayText) {
// List<String> result = [];
// // Einfacher aber effektiver Ansatz für wohlgeformte Teile
// final matches = RegExp(r'"([^"]+)"').allMatches(arrayText);
// for (var match in matches) {
// if (match.groupCount >= 1) {
// result.add(match.group(1)!);
// }
// }
// return result;
// }
// /// Fetches series information from the HTML of a book page
// ///
// /// Parameters:
// ///
// /// - `bookId`: The ID of the book to fetch series info for
// Future<Map<String, dynamic>?> fetchSeriesInfoFromHtml(String bookId) async {
// logger.i('Fetching series info from HTML for book ID: $bookId');
// try {
// final response = await _apiService.get(
// '/book/$bookId',
// AuthMethod.cookie,
// );
// if (response.statusCode == 200) {
// final html = response.body;
// final RegExp seriesRegex = RegExp(
// r'''href=['"](/series/stored/(\d+))['"]''',
// caseSensitive: false,
// );
// final Match? match = seriesRegex.firstMatch(html);
// if (match != null && match.groupCount >= 2) {
// final String seriesLink = match.group(1)!;
// final String seriesIdStr = match.group(2)!;
// final int? seriesId = int.tryParse(seriesIdStr);
// if (seriesId != null) {
// final int linkEndIndex = match.end;
// final int tagCloseIndex = html.indexOf('>', linkEndIndex);
// if (tagCloseIndex != -1) {
// final int closingTagIndex = html.indexOf('</a>', tagCloseIndex);
// if (closingTagIndex != -1) {
// final String seriesName =
// html.substring(tagCloseIndex + 1, closingTagIndex).trim();
// logger.i(
// 'Found series info in HTML: Name="$seriesName", ID=$seriesId',
// );
// return {'name': seriesName, 'id': seriesId};
// }
// }
// logger.i(
// 'Found series ID in HTML: $seriesId (Name extraction failed)',
// );
// return {'name': 'Unknown Series', 'id': seriesId};
// } else {
// logger.w('Found series link, but failed to parse ID: $seriesIdStr');
// }
// } else {
// logger.i('No series link matching pattern found in HTML');
// }
// } else {
// logger.w('Error fetching book page: ${response.statusCode}');
// }
// } catch (e) {
// logger.e('Error fetching series info from HTML: $e');
// }
// return null;
// }
// /// Extend a book object with series information if available
// ///
// /// Parameters:
// ///
// /// - `book`: The book object to enhance
// Future<BookItem> enhanceBookWithSeriesInfo(BookItem book) async {
// if ((book.series == null || book.series!.isEmpty) && book.id.isNotEmpty) {
// final seriesInfo = await fetchSeriesInfoFromHtml(book.id);
// if (seriesInfo != null && seriesInfo['name'] != null) {
// final String seriesName = seriesInfo['name'];
// final int seriesId = seriesInfo['id'];
// return BookItem(
// id: book.id,
// title: book.title,
// author: book.author,
// uuid: book.uuid,
// publisher: book.publisher,
// updated: book.updated,
// published: book.published,
// language: book.language,
// categories: book.categories,
// summary: book.summary,
// fileSize: book.fileSize,
// series: seriesName,
// seriesIndex: book.seriesIndex,
// formats: book.formats,
// downloadLinks: book.downloadLinks,
// rating: book.rating,
// coverUrl: book.coverUrl,
// thumbnailUrl: book.thumbnailUrl,
// seriesId: seriesId,
// );
// }
// }
// return book;
// }
// /// Fetches all categories and their IDs from the server
// Future<Map<String, int>> fetchCategoryMappings() async {
// logger.i('Fetching category mappings');
// final categoriesMap = <String, int>{};
// try {
// final response = await _apiService.get('/category', AuthMethod.cookie);
// if (response.statusCode == 200) {
// final html = response.body;
// // Use regex to extract categories and their IDs
// final RegExp categoryRegex = RegExp(
// r'''<a\s+id="list_\d+"\s+href="/category/stored/(\d+)">\s*(\w[^<]+)''',
// caseSensitive: false,
// multiLine: true,
// dotAll: true,
// );
// final matches = categoryRegex.allMatches(html);
// for (var match in matches) {
// if (match.groupCount >= 2) {
// final categoryId = int.tryParse(match.group(1)!) ?? 0;
// final categoryName = match.group(2)!.trim();
// if (categoryId > 0 && categoryName.isNotEmpty) {
// categoriesMap[categoryName] = categoryId;
// }
// }
// }
// logger.i('Found ${categoriesMap.length} categories with IDs');
// } else {
// logger.w('Failed to fetch category page: ${response.statusCode}');
// }
// } catch (e) {
// logger.e('Error fetching category mappings: $e');
// }
// return categoriesMap;
// }
// /// Enhance a book with category IDs
// ///
// /// Parameters:
// ///
// /// - `book`: The book object to enhance
// /// - `categoryMap`: A map of category names to IDs
// Future<BookItem> enhanceBookWithCategoryIds(
// BookItem book,
// Map<String, int> categoryMap,
// ) async {
// final bookCategoryIds = <String, int>{};
// if (book.categories.isEmpty) {
// return book;
// }
// for (final category in book.categories) {
// if (categoryMap.containsKey(category)) {
// bookCategoryIds[category] = categoryMap[category]!;
// }
// }
// if (bookCategoryIds.isNotEmpty) {
// return BookItem(
// id: book.id,
// title: book.title,
// author: book.author,
// uuid: book.uuid,
// publisher: book.publisher,
// updated: book.updated,
// published: book.published,
// language: book.language,
// categories: book.categories,
// categoriesMap: bookCategoryIds,
// summary: book.summary,
// fileSize: book.fileSize,
// series: book.series,
// seriesIndex: book.seriesIndex,
// formats: book.formats,
// downloadLinks: book.downloadLinks,
// rating: book.rating,
// coverUrl: book.coverUrl,
// thumbnailUrl: book.thumbnailUrl,
// authorSort: book.authorSort,
// seriesId: book.seriesId,
// );
// }
// return book;
// }
}
+29
View File
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
extension SnackBarExtension on BuildContext {
/// Shows a SnackBar with the given [message].
///
/// Parameters:
///
/// - [message]: The message to display in the SnackBar.
/// - [isError]: Whether the message is an error message.
/// - [duration]: The duration for which the SnackBar should be displayed.
void showSnackBar(
String message, {
bool isError = false,
Duration duration = const Duration(seconds: 3),
}) {
ScaffoldMessenger.of(this).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor:
isError
? Theme.of(this).colorScheme.error
: Theme.of(this).colorScheme.primary,
duration: duration,
// behavior: SnackBarBehavior.floating,
// margin: const EdgeInsets.all(8),
),
);
}
}
@@ -0,0 +1,338 @@
import 'package:calibre_web_companion/features/book_details/data/repositories/book_details_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:logger/logger.dart';
import 'book_details_event.dart';
import 'book_details_state.dart';
class BookDetailsBloc extends Bloc<BookDetailsEvent, BookDetailsState> {
final BookDetailsRepository _repository;
final Logger _logger;
BookDetailsBloc({required BookDetailsRepository repository, Logger? logger})
: _repository = repository,
_logger = logger ?? Logger(),
super(const BookDetailsState()) {
on<LoadBookDetails>(_onLoadBookDetails);
on<ReloadBookDetails>(_onReloadBookDetails);
on<ToggleReadStatus>(_onToggleReadStatus);
on<ToggleArchiveStatus>(_onToggleArchiveStatus);
on<DownloadBook>(_onDownloadBook);
on<SendBookByEmail>(_onSendBookByEmail);
on<OpenBookInReader>(_onOpenBookInReader);
on<OpenBookInBrowser>(_onOpenBookInBrowser);
on<UpdateDownloadProgress>(_onUpdateDownloadProgress);
}
Future<void> _onLoadBookDetails(
LoadBookDetails event,
Emitter<BookDetailsState> emit,
) async {
try {
_logger.i('Loading book details: ${event.bookUuid}');
emit(
state.copyWith(status: BookDetailsStatus.loading, errorMessage: null),
);
final bookDetails = await _repository.getBookDetails(
event.bookListModel,
event.bookUuid,
);
// Check read status
final isRead = await _repository.checkIfBookIsRead(bookDetails.id);
// Check archive status
final isArchived = await _repository.checkIfBookIsArchived(
bookDetails.id,
);
emit(
state.copyWith(
status: BookDetailsStatus.loaded,
bookDetails: bookDetails,
isBookRead: isRead,
isBookArchived: isArchived,
),
);
} catch (e) {
_logger.e('Error loading book details: $e');
emit(
state.copyWith(
status: BookDetailsStatus.error,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onReloadBookDetails(
ReloadBookDetails event,
Emitter<BookDetailsState> emit,
) async {
try {
_logger.i('Reloading book details: ${event.bookUuid}');
emit(
state.copyWith(status: BookDetailsStatus.loading, errorMessage: null),
);
final bookDetails = await _repository.getBookDetails(
event.bookListModel,
event.bookUuid,
);
// Check read status
final isRead = await _repository.checkIfBookIsRead(bookDetails.id);
// Check archive status
final isArchived = await _repository.checkIfBookIsArchived(
bookDetails.id,
);
emit(
state.copyWith(
status: BookDetailsStatus.loaded,
bookDetails: bookDetails,
isBookRead: isRead,
isBookArchived: isArchived,
),
);
} catch (e) {
_logger.e('Error reloading book details: $e');
emit(
state.copyWith(
status: BookDetailsStatus.error,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onToggleReadStatus(
ToggleReadStatus event,
Emitter<BookDetailsState> emit,
) async {
try {
_logger.i('Toggling read status: ${event.bookId}');
emit(state.copyWith(readStatusState: ReadStatusState.loading));
final success = await _repository.toggleReadStatus(event.bookId);
if (success) {
emit(
state.copyWith(
readStatusState: ReadStatusState.success,
isBookRead: !state.isBookRead,
),
);
} else {
emit(
state.copyWith(
readStatusState: ReadStatusState.error,
errorMessage: 'Failed to toggle read status',
),
);
}
} catch (e) {
_logger.e('Error toggling read status: $e');
emit(
state.copyWith(
readStatusState: ReadStatusState.error,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onToggleArchiveStatus(
ToggleArchiveStatus event,
Emitter<BookDetailsState> emit,
) async {
try {
_logger.i('Toggling archive status: ${event.bookId}');
emit(state.copyWith(archiveStatusState: ArchiveStatusState.loading));
final success = await _repository.toggleArchiveStatus(event.bookId);
if (success) {
emit(
state.copyWith(
archiveStatusState: ArchiveStatusState.success,
isBookArchived: !state.isBookArchived,
),
);
} else {
emit(
state.copyWith(
archiveStatusState: ArchiveStatusState.error,
errorMessage: 'Failed to toggle archive status',
),
);
}
} catch (e) {
_logger.e('Error toggling archive status: $e');
emit(
state.copyWith(
archiveStatusState: ArchiveStatusState.error,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onDownloadBook(
DownloadBook event,
Emitter<BookDetailsState> emit,
) async {
if (state.bookDetails == null) {
emit(
state.copyWith(
downloadState: DownloadState.error,
errorMessage: 'Book details not available',
),
);
return;
}
try {
_logger.i(
'Downloading book: ${state.bookDetails!.title} in ${event.format} format',
);
emit(
state.copyWith(
downloadState: DownloadState.downloading,
downloadProgress: 0,
),
);
final filePath = await _repository.downloadBook(
state.bookDetails!,
event.selectedDirectory,
event.schema,
format: event.format,
progressCallback: (progress) {
add(UpdateDownloadProgress(progress));
},
);
emit(
state.copyWith(
downloadState: DownloadState.success,
downloadedFilePath: filePath,
),
);
} catch (e) {
_logger.e('Error downloading book: $e');
emit(
state.copyWith(
downloadState: DownloadState.error,
errorMessage: e.toString(),
),
);
}
}
void _onUpdateDownloadProgress(
UpdateDownloadProgress event,
Emitter<BookDetailsState> emit,
) {
emit(state.copyWith(downloadProgress: event.progress));
}
Future<void> _onSendBookByEmail(
SendBookByEmail event,
Emitter<BookDetailsState> emit,
) async {
try {
_logger.i('Sending book via email: ${event.bookId}');
emit(state.copyWith(emailState: EmailState.sending));
final success = await _repository.sendViaEmail(
event.bookId,
event.format,
event.conversion,
);
if (success) {
emit(state.copyWith(emailState: EmailState.success));
} else {
emit(
state.copyWith(
emailState: EmailState.error,
errorMessage: 'Failed to send book via email',
),
);
}
} catch (e) {
_logger.e('Error sending book via email: $e');
emit(
state.copyWith(
emailState: EmailState.error,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onOpenBookInReader(
OpenBookInReader event,
Emitter<BookDetailsState> emit,
) async {
if (state.bookDetails == null) {
emit(
state.copyWith(
openInReaderState: OpenInReaderState.error,
errorMessage: 'Book details not available',
),
);
return;
}
try {
_logger.i('Opening book in reader: ${state.bookDetails!.title}');
emit(state.copyWith(openInReaderState: OpenInReaderState.loading));
final success = await _repository.openInReader(
state.bookDetails!,
event.selectedDirectory,
event.schema,
);
if (success) {
emit(state.copyWith(openInReaderState: OpenInReaderState.success));
} else {
emit(
state.copyWith(
openInReaderState: OpenInReaderState.error,
errorMessage: 'Failed to open book in reader',
),
);
}
} catch (e) {
_logger.e('Error opening book in reader: $e');
emit(
state.copyWith(
openInReaderState: OpenInReaderState.error,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onOpenBookInBrowser(
OpenBookInBrowser event,
Emitter<BookDetailsState> emit,
) async {
if (state.bookDetails == null) {
return;
}
try {
_logger.i('Opening book in browser: ${state.bookDetails!.title}');
await _repository.openInBrowser(state.bookDetails!);
} catch (e) {
_logger.e('Error opening book in browser: $e');
// We don't update state for browser opening
}
}
}
@@ -0,0 +1,105 @@
import 'package:calibre_web_companion/features/book_view/data/models/book_view_model.dart';
import 'package:equatable/equatable.dart';
import 'package:calibre_web_companion/features/book_details/data/repositories/book_details_repository.dart';
abstract class BookDetailsEvent extends Equatable {
const BookDetailsEvent();
@override
List<Object?> get props => [];
}
class LoadBookDetails extends BookDetailsEvent {
final BookViewModel bookListModel;
final String bookUuid;
const LoadBookDetails(this.bookListModel, this.bookUuid);
@override
List<Object?> get props => [bookUuid];
}
class ReloadBookDetails extends BookDetailsEvent {
final BookViewModel bookListModel;
final String bookUuid;
const ReloadBookDetails(this.bookListModel, this.bookUuid);
@override
List<Object?> get props => [bookUuid];
}
class ToggleReadStatus extends BookDetailsEvent {
final int bookId;
const ToggleReadStatus(this.bookId);
@override
List<Object?> get props => [bookId];
}
class ToggleArchiveStatus extends BookDetailsEvent {
final int bookId;
const ToggleArchiveStatus(this.bookId);
@override
List<Object?> get props => [bookId];
}
class DownloadBook extends BookDetailsEvent {
final String selectedDirectory;
final DownloadSchema schema;
final String format;
const DownloadBook({
required this.selectedDirectory,
required this.schema,
this.format = 'epub',
});
@override
List<Object?> get props => [selectedDirectory, schema, format];
}
class SendBookByEmail extends BookDetailsEvent {
final String bookId;
final String format;
final int conversion;
const SendBookByEmail({
required this.bookId,
required this.format,
required this.conversion,
});
@override
List<Object?> get props => [bookId, format, conversion];
}
class OpenBookInReader extends BookDetailsEvent {
final String selectedDirectory;
final DownloadSchema schema;
const OpenBookInReader({
required this.selectedDirectory,
required this.schema,
});
@override
List<Object?> get props => [selectedDirectory, schema];
}
class OpenBookInBrowser extends BookDetailsEvent {
const OpenBookInBrowser();
}
class UpdateDownloadProgress extends BookDetailsEvent {
final int progress;
const UpdateDownloadProgress(this.progress);
@override
List<Object?> get props => [progress];
}
@@ -0,0 +1,97 @@
import 'package:equatable/equatable.dart';
import 'package:calibre_web_companion/features/book_details/data/models/book_details_model.dart';
enum BookDetailsStatus { initial, loading, loaded, error }
enum ReadStatusState { initial, loading, success, error }
enum ArchiveStatusState { initial, loading, success, error }
enum DownloadState {
initial,
selectingDestination,
downloading,
success,
error,
}
enum EmailState { initial, sending, success, error }
enum OpenInReaderState { initial, loading, success, error }
class BookDetailsState extends Equatable {
final BookDetailsStatus status;
final BookDetailsModel? bookDetails;
final String? errorMessage;
final bool isBookRead;
final ReadStatusState readStatusState;
final bool isBookArchived;
final ArchiveStatusState archiveStatusState;
final DownloadState downloadState;
final int downloadProgress;
final String? downloadedFilePath;
final EmailState emailState;
final OpenInReaderState openInReaderState;
const BookDetailsState({
this.status = BookDetailsStatus.initial,
this.bookDetails,
this.errorMessage,
this.isBookRead = false,
this.readStatusState = ReadStatusState.initial,
this.isBookArchived = false,
this.archiveStatusState = ArchiveStatusState.initial,
this.downloadState = DownloadState.initial,
this.downloadProgress = 0,
this.downloadedFilePath,
this.emailState = EmailState.initial,
this.openInReaderState = OpenInReaderState.initial,
});
BookDetailsState copyWith({
BookDetailsStatus? status,
BookDetailsModel? bookDetails,
String? errorMessage,
bool? isBookRead,
ReadStatusState? readStatusState,
bool? isBookArchived,
ArchiveStatusState? archiveStatusState,
DownloadState? downloadState,
int? downloadProgress,
String? downloadedFilePath,
EmailState? emailState,
OpenInReaderState? openInReaderState,
}) {
return BookDetailsState(
status: status ?? this.status,
bookDetails: bookDetails ?? this.bookDetails,
errorMessage: errorMessage,
isBookRead: isBookRead ?? this.isBookRead,
readStatusState: readStatusState ?? this.readStatusState,
isBookArchived: isBookArchived ?? this.isBookArchived,
archiveStatusState: archiveStatusState ?? this.archiveStatusState,
downloadState: downloadState ?? this.downloadState,
downloadProgress: downloadProgress ?? this.downloadProgress,
downloadedFilePath: downloadedFilePath ?? this.downloadedFilePath,
emailState: emailState ?? this.emailState,
openInReaderState: openInReaderState ?? this.openInReaderState,
);
}
@override
List<Object?> get props => [
status,
bookDetails,
errorMessage,
isBookRead,
readStatusState,
isBookArchived,
archiveStatusState,
downloadState,
downloadProgress,
downloadedFilePath,
emailState,
openInReaderState,
];
}
@@ -0,0 +1,425 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:logger/logger.dart';
import 'package:open_file/open_file.dart';
import 'package:path/path.dart' as path;
import 'package:url_launcher/url_launcher.dart';
import 'package:calibre_web_companion/core/services/api_service.dart';
import 'package:calibre_web_companion/core/services/json_service.dart';
import 'package:calibre_web_companion/features/book_details/data/models/book_details_model.dart';
import 'package:calibre_web_companion/features/book_details/data/repositories/book_details_repository.dart';
import 'package:calibre_web_companion/features/book_view/data/models/book_view_model.dart';
class BookDetailsDatasource {
final ApiService _apiService;
// final JsonService _jsonService;
final Logger _logger;
BookDetailsDatasource({
ApiService? apiService,
JsonService? jsonService,
Logger? logger,
}) : _apiService = apiService ?? ApiService(),
// _jsonService = jsonService ?? JsonService(),
_logger = logger ?? Logger();
Future<BookDetailsModel> fetchBookDetails(
BookViewModel bookListModel,
String bookUuid,
) async {
try {
final response = await _apiService.get(
'/ajax/book/$bookUuid',
AuthMethod.basic,
);
_logger.d(response.body);
if (response.statusCode == 200) {
try {
// Try parsing the JSON response
final bookJson = json.decode(response.body);
final book = BookDetailsModel.fromBookListModel(
bookListModel,
bookJson,
);
_logger.i("Fetched book details: ${book.title}");
return book;
} catch (jsonError) {
_logger.w('JSON parsing failed: $jsonError.');
throw Exception('Failed to parse book details JSON: $jsonError');
}
} else {
throw Exception('Server error: ${response.statusCode}');
}
} catch (e) {
_logger.e("Error fetching book details: $e");
throw Exception("Failed to fetch book details: $e");
}
}
Future<bool> toggleReadStatus(int bookId) async {
try {
_logger.i('Toggling read status for book: $bookId');
final response = await _apiService.post(
'/ajax/toggleread/$bookId',
null,
{},
AuthMethod.cookie,
useCsrf: true,
contentType: 'application/x-www-form-urlencoded',
);
if (response.statusCode == 200) {
_logger.i('Successfully toggled read status');
return true;
} else {
_logger.e('Failed to toggle read status: ${response.statusCode}');
throw Exception(
'Failed to toggle read status (${response.statusCode})',
);
}
} catch (e) {
_logger.e('Error toggling read status: $e');
throw Exception('Error toggling read status: $e');
}
}
Future<bool> toggleArchiveStatus(int bookId) async {
try {
_logger.i('Toggling archive status for book: $bookId');
final response = await _apiService.post(
'/ajax/togglearchived/$bookId',
null,
{},
AuthMethod.cookie,
useCsrf: true,
contentType: 'application/x-www-form-urlencoded',
);
if (response.statusCode == 200) {
_logger.i('Successfully toggled archive status');
return true;
} else {
_logger.e('Failed to toggle archive status: ${response.statusCode}');
throw Exception(
'Failed to toggle archive status (${response.statusCode})',
);
}
} catch (e) {
_logger.e('Error toggling archive status: $e');
throw Exception('Error toggling archive status: $e');
}
}
Future<bool> checkIfBookIsRead(int bookId) async {
try {
_logger.i('Checking if book is read: $bookId');
final response = await _apiService.get(
'/read/stored/',
AuthMethod.cookie,
);
if (response.statusCode == 200) {
final pattern = 'href="/book/$bookId"';
final isRead = response.body.contains(pattern);
_logger.i("Book $bookId read status: $isRead");
return isRead;
} else {
_logger.w("Failed to check read status: ${response.statusCode}");
return false;
}
} catch (e) {
_logger.e('Error checking if book is read: $e');
return false;
}
}
Future<bool> checkIfBookIsArchived(int bookId) async {
try {
_logger.i('Checking if book is archived: $bookId');
final response = await _apiService.get(
'/archived/stored/',
AuthMethod.cookie,
);
if (response.statusCode == 200) {
final pattern = 'href="/book/$bookId"';
final isArchived = response.body.contains(pattern);
_logger.i("Book $bookId archived status: $isArchived");
return isArchived;
} else {
_logger.w("Failed to check archived status: ${response.statusCode}");
return false;
}
} catch (e) {
_logger.e('Error checking if book is archived: $e');
return false;
}
}
Future<Uint8List?> downloadBookBytes(String bookId, String format) async {
try {
_logger.i('Downloading book bytes - BookId: $bookId, Format: $format');
final response = await _apiService.getStream(
'/download/$bookId/$format/$bookId.$format',
AuthMethod.cookie,
);
if (response.statusCode == 200) {
_logger.i('Successfully downloaded book bytes');
return await response.stream.toBytes();
} else {
_logger.e('Error downloading book bytes: HTTP ${response.statusCode}');
return null;
}
} catch (e) {
_logger.e('Exception downloading book bytes: $e');
throw Exception('Failed to download book bytes: $e');
}
}
Future<String> downloadBook(
BookDetailsModel book,
String selectedDirectory,
DownloadSchema schema, {
String format = 'epub',
Function(int)? progressCallback,
}) async {
try {
String filePath = await _createPathBasedOnSchema(
selectedDirectory,
book,
format,
schema,
);
final file = File(filePath);
if (await file.exists()) {
_logger.i('File already exists: $filePath');
return filePath;
}
await Directory(path.dirname(filePath)).create(recursive: true);
final tempFilePath = '$filePath.downloading';
final tempFile = File(tempFilePath);
final response = await _apiService.getStream(
'/download/${book.id}/$format/${book.id}.$format',
AuthMethod.cookie,
);
final contentLength = response.contentLength ?? -1;
_logger.i(
'Download response status: ${response.statusCode}, Content length: $contentLength',
);
final sink = tempFile.openWrite();
int receivedBytes = 0;
try {
await for (final chunk in response.stream) {
receivedBytes += chunk.length;
sink.add(chunk);
if (contentLength > 0 && progressCallback != null) {
final progress = (receivedBytes / contentLength * 100).round();
progressCallback(progress);
_logger.d(
'Download progress: $progress%, $receivedBytes/$contentLength bytes',
);
}
}
await sink.flush();
await sink.close();
if (await tempFile.exists()) {
await tempFile.rename(filePath);
} else {
throw Exception('Temporary file was not created correctly');
}
_logger.i('Download complete: $filePath with $receivedBytes bytes');
return filePath;
} catch (e) {
_logger.e('Error during download: $e');
await sink.close();
if (await tempFile.exists()) {
await tempFile.delete();
}
rethrow;
}
} catch (e) {
_logger.e('Exception while downloading book: $e');
throw Exception('Error downloading book: $e');
}
}
Future<String> _createPathBasedOnSchema(
String baseDirectory,
BookDetailsModel book,
String format,
DownloadSchema schema,
) async {
// Sanitize the file name to prevent invalid characters
final safeTitle = book.title.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
final safeAuthor = book.authors.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
final fileName = '$safeTitle.$format';
String? safeSeries;
if (book.series.isNotEmpty) {
safeSeries = book.series.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
}
String filePath;
Directory directory;
switch (schema) {
case DownloadSchema.flat:
filePath = path.join(baseDirectory, fileName);
break;
case DownloadSchema.authorOnly:
final authorDir = path.join(baseDirectory, safeAuthor);
directory = Directory(authorDir);
await directory.create(recursive: true);
filePath = path.join(authorDir, fileName);
break;
case DownloadSchema.authorBook:
final bookDir = path.join(baseDirectory, safeAuthor, safeTitle);
directory = Directory(bookDir);
await directory.create(recursive: true);
filePath = path.join(bookDir, fileName);
break;
case DownloadSchema.authorSeriesBook:
if (safeSeries != null && safeSeries.isNotEmpty) {
final bookDir = path.join(
baseDirectory,
safeAuthor,
safeSeries,
safeTitle,
);
directory = Directory(bookDir);
await directory.create(recursive: true);
filePath = path.join(bookDir, fileName);
} else {
final bookDir = path.join(baseDirectory, safeAuthor, safeTitle);
directory = Directory(bookDir);
await directory.create(recursive: true);
filePath = path.join(bookDir, fileName);
}
break;
}
_logger.d('Created path based on schema $schema: $filePath');
return filePath;
}
Future<bool> sendViaEmail(
String bookId,
String format,
int conversion,
) async {
try {
_logger.i(
'Sending book via email - BookId: $bookId, Format: $format, Conversion: $conversion',
);
final response = await _apiService.post(
'/send/$bookId/$format/$conversion',
null,
{},
AuthMethod.cookie,
useCsrf: true,
);
if (response.statusCode == 200) {
_logger.i('Successfully sent book via email');
return true;
} else {
_logger.e('Failed to send book via email: ${response.statusCode}');
throw Exception('Failed to send email (${response.statusCode})');
}
} catch (e) {
_logger.e('Error sending book via email: $e');
throw Exception('Error sending book via email: $e');
}
}
Future<bool> openInReader(
BookDetailsModel book,
String selectedDirectory,
DownloadSchema schema,
) async {
try {
_logger.i('Opening book in reader: ${book.title}');
String format = 'epub';
if (book.formats.isNotEmpty) {
format = book.formats.first.toLowerCase();
}
final filePath = await downloadBook(
book,
selectedDirectory,
schema,
format: format,
);
if (filePath.isEmpty) {
_logger.e('Error downloading file for reader');
throw Exception('Error downloading file');
}
final result = await OpenFile.open(filePath);
if (result.type != ResultType.done) {
_logger.e('Error while opening the file: ${result.message}');
throw Exception('Error while opening: ${result.message}');
}
_logger.i('Opened book successfully');
return true;
} catch (e) {
_logger.e('Error opening book in reader: $e');
throw Exception('Error opening book in reader: $e');
}
}
Future<void> openInBrowser(BookDetailsModel book) async {
try {
final baseUrl = _apiService.getBaseUrl();
if (baseUrl.isEmpty) {
_logger.w('No server URL found');
throw Exception('Server URL missing');
}
final Uri url = Uri.parse('$baseUrl/book/${book.id}');
if (!await launchUrl(url)) {
throw Exception('Could not launch $url');
}
_logger.i('Opened book in browser: $url');
} catch (e) {
_logger.e('Error opening book in browser: $e');
throw Exception('Error opening book in browser: $e');
}
}
}
@@ -0,0 +1,108 @@
import 'package:calibre_web_companion/features/book_details/data/models/form_metadata_model.dart';
import 'package:calibre_web_companion/features/book_view/data/models/book_view_model.dart';
class BookDetailsModel extends BookViewModel {
final FormatMetadata formatMetadata;
final List<String> formats;
final String cover;
final String thumbnail;
final Map<String, String> mainFormat;
final Map<String, String> otherFormats;
final String titleSort;
const BookDetailsModel({
required super.id,
required super.uuid,
required super.title,
required super.authors,
super.authorSort = '',
super.comments = '',
super.data = '',
super.flags = false,
super.hasCover = false,
super.identifiers = '',
super.isArchived = false,
super.isbn = '',
super.languages = '',
super.lastModified = '',
super.path = '',
super.pubdate = '',
super.publishers = '',
super.ratings = '',
super.readStatus = false,
super.registry = '',
super.series = '',
super.seriesIndex = 0.0,
super.sort = '',
super.tags = const [],
super.timestamp = '',
this.formats = const [],
this.cover = '',
this.formatMetadata = const FormatMetadata(formats: {}),
this.mainFormat = const {},
this.otherFormats = const {},
this.thumbnail = '',
this.titleSort = '',
});
@override
List<Object?> get props => [
...super.props,
formats,
cover,
formatMetadata,
mainFormat,
otherFormats,
thumbnail,
titleSort,
];
factory BookDetailsModel.fromBookListModel(
BookViewModel bookListModel, [
Map<String, dynamic> additionalData = const {},
]) {
return BookDetailsModel(
id: bookListModel.id,
uuid: bookListModel.uuid,
title: bookListModel.title,
authors: bookListModel.authors,
authorSort: bookListModel.authorSort,
comments: bookListModel.comments,
data: bookListModel.data,
flags: bookListModel.flags,
hasCover: bookListModel.hasCover,
identifiers: bookListModel.identifiers,
isArchived: bookListModel.isArchived,
isbn: bookListModel.isbn,
languages: bookListModel.languages,
lastModified: bookListModel.lastModified,
path: bookListModel.path,
pubdate: bookListModel.pubdate,
publishers: bookListModel.publishers,
ratings: bookListModel.ratings,
readStatus: bookListModel.readStatus,
registry: bookListModel.registry,
series: bookListModel.series,
seriesIndex: bookListModel.seriesIndex,
sort: bookListModel.sort,
tags: bookListModel.tags,
timestamp: bookListModel.timestamp,
formats:
(additionalData['formats'] as List).map((f) => f.toString()).toList(),
cover: additionalData['cover'],
formatMetadata: FormatMetadata.fromJson(additionalData),
mainFormat: Map<String, String>.from(
(additionalData['main_format'] as Map).map(
(key, value) => MapEntry(key.toString(), value.toString()),
),
),
otherFormats: Map<String, String>.from(
(additionalData['other_formats'] as Map).map(
(key, value) => MapEntry(key.toString(), value.toString()),
),
),
thumbnail: additionalData['thumbnail'] ?? '',
titleSort: additionalData['title_sort'] ?? '',
);
}
}
@@ -0,0 +1,64 @@
import 'package:equatable/equatable.dart';
import 'package:logger/logger.dart';
class FormatMetadataModel extends Equatable {
final String format;
final int? size;
final String? mtime;
final String? path;
const FormatMetadataModel({
required this.format,
this.size,
this.mtime,
this.path,
});
factory FormatMetadataModel.fromJson(
String format,
Map<String, dynamic> json,
) {
return FormatMetadataModel(
format: format.toLowerCase(),
size: int.tryParse(json['size']),
mtime: json['mtime'],
path: json['path'],
);
}
@override
List<Object?> get props => [format, size, mtime, path];
}
class FormatMetadata extends Equatable {
final Map<String, FormatMetadataModel> formats;
static final Logger _logger = Logger();
const FormatMetadata({required this.formats});
/// Parse the entire format_metadata structure from a JSON response
factory FormatMetadata.fromJson(Map<String, dynamic> json) {
final Map<String, FormatMetadataModel> formats = {};
try {
final formatMetadataJson = json['format_metadata'] as Map;
formatMetadataJson.forEach((format, metadata) {
if (metadata is Map<String, dynamic>) {
formats[format.toLowerCase()] = FormatMetadataModel.fromJson(
format,
metadata,
);
}
});
} catch (e) {
_logger.e('Error parsing format metadata: $e');
}
return FormatMetadata(formats: formats);
}
@override
List<Object?> get props => [formats];
}
@@ -0,0 +1,132 @@
import 'dart:typed_data';
import 'package:logger/logger.dart';
import 'package:calibre_web_companion/features/book_details/data/datasources/book_details_datasource.dart';
import 'package:calibre_web_companion/features/book_details/data/models/book_details_model.dart';
import 'package:calibre_web_companion/features/book_view/data/models/book_view_model.dart';
enum DownloadSchema { flat, authorOnly, authorBook, authorSeriesBook }
class BookDetailsRepository {
final BookDetailsDatasource _datasource;
final Logger _logger;
BookDetailsRepository({
required BookDetailsDatasource datasource,
Logger? logger,
}) : _datasource = datasource,
_logger = logger ?? Logger();
Future<BookDetailsModel> getBookDetails(
BookViewModel bookListModel,
String bookUuid,
) async {
try {
return await _datasource.fetchBookDetails(bookListModel, bookUuid);
} catch (e) {
_logger.e('Error fetching book details: $e');
throw Exception('Failed to load book details');
}
}
Future<bool> toggleReadStatus(int bookId) async {
try {
return await _datasource.toggleReadStatus(bookId);
} catch (e) {
_logger.e('Error toggling read status: $e');
throw Exception('Failed to toggle read status');
}
}
Future<bool> toggleArchiveStatus(int bookId) async {
try {
return await _datasource.toggleArchiveStatus(bookId);
} catch (e) {
_logger.e('Error toggling archive status: $e');
throw Exception('Failed to toggle archive status');
}
}
Future<bool> checkIfBookIsRead(int bookId) async {
try {
return await _datasource.checkIfBookIsRead(bookId);
} catch (e) {
_logger.e('Error checking read status: $e');
return false;
}
}
Future<bool> checkIfBookIsArchived(int bookId) async {
try {
return await _datasource.checkIfBookIsArchived(bookId);
} catch (e) {
_logger.e('Error checking archive status: $e');
return false;
}
}
Future<Uint8List?> downloadBookBytes(String bookId, String format) async {
try {
return await _datasource.downloadBookBytes(bookId, format);
} catch (e) {
_logger.e('Error downloading book bytes: $e');
throw Exception('Failed to download book');
}
}
Future<String> downloadBook(
BookDetailsModel book,
String selectedDirectory,
DownloadSchema schema, {
String format = 'epub',
Function(int)? progressCallback,
}) async {
try {
return await _datasource.downloadBook(
book,
selectedDirectory,
schema,
format: format,
progressCallback: progressCallback,
);
} catch (e) {
_logger.e('Error downloading book: $e');
throw Exception('Failed to download book');
}
}
Future<bool> sendViaEmail(
String bookId,
String format,
int conversion,
) async {
try {
return await _datasource.sendViaEmail(bookId, format, conversion);
} catch (e) {
_logger.e('Error sending book via email: $e');
throw Exception('Failed to send book via email');
}
}
Future<bool> openInReader(
BookDetailsModel book,
String selectedDirectory,
DownloadSchema schema,
) async {
try {
return await _datasource.openInReader(book, selectedDirectory, schema);
} catch (e) {
_logger.e('Error opening book in reader: $e');
throw Exception('Failed to open book in reader');
}
}
Future<void> openInBrowser(BookDetailsModel book) async {
try {
await _datasource.openInBrowser(book);
} catch (e) {
_logger.e('Error opening book in browser: $e');
throw Exception('Failed to open book in browser');
}
}
}
@@ -0,0 +1,806 @@
// import 'dart:convert';
// import 'package:cached_network_image/cached_network_image.dart';
// import 'package:calibre_web_companion/core/services/api_service.dart';
// import 'package:calibre_web_companion/core/services/app_transition.dart';
// import 'package:calibre_web_companion/core/services/snackbar.dart';
// import 'package:file_picker/file_picker.dart';
// import 'package:flutter/material.dart';
// import 'package:flutter_bloc/flutter_bloc.dart';
// import 'package:intl/intl.dart' as intl;
// import 'package:flutter_gen/gen_l10n/app_localizations.dart';
// import 'package:skeletonizer/skeletonizer.dart';
// import 'package:calibre_web_companion/features/book_details/bloc/book_details_bloc.dart';
// import 'package:calibre_web_companion/features/book_details/bloc/book_details_event.dart';
// import 'package:calibre_web_companion/features/book_details/bloc/book_details_state.dart';
// import 'package:calibre_web_companion/features/book_details/data/models/book_details_model.dart';
// import 'package:calibre_web_companion/features/book_details/data/repositories/book_details_repository.dart';
// import 'package:calibre_web_companion/features/book_list/data/models/book_list_model.dart';
// import 'package:calibre_web_companion/features/book_list/presentation/pages/book_list_page.dart';
// class BookDetailsPage extends StatelessWidget {
// final BookListModel bookListModel;
// final String bookUuid;
// const BookDetailsPage({
// super.key,
// required this.bookListModel,
// required this.bookUuid,
// });
// @override
// Widget build(BuildContext context) {
// final localizations = AppLocalizations.of(context)!;
// return BlocProvider(
// create:
// (context) =>
// BookDetailsBloc(repository: context.read<BookDetailsRepository>())
// ..add(LoadBookDetails(bookListModel, bookUuid)),
// child: BlocConsumer<BookDetailsBloc, BookDetailsState>(
// listener: (context, state) {
// // Handle state changes that require user feedback
// if (state.readStatusState == ReadStatusState.success) {
// context.showSnackBar(
// state.isBookRead
// ? localizations.markedAsReadSuccessfully
// : localizations.markedAsUnreadSuccessfully,
// );
// } else if (state.readStatusState == ReadStatusState.error) {
// context.showSnackBar(
// state.isBookRead
// ? localizations.markedAsReadFailed
// : localizations.markedAsUnreadFailed,
// isError: true,
// );
// }
// if (state.archiveStatusState == ArchiveStatusState.success) {
// context.showSnackBar(
// state.isBookArchived
// ? localizations.archivedBookSuccessfully
// : localizations.unarchivedBookSuccessfully,
// );
// } else if (state.archiveStatusState == ArchiveStatusState.error) {
// context.showSnackBar(
// state.isBookArchived
// ? localizations.archivedBookFailed
// : localizations.unarchivedBookFailed,
// isError: true,
// );
// }
// if (state.openInReaderState == OpenInReaderState.success) {
// context.showSnackBar(
// localizations.bookOpenedExternallySuccessfully,
// );
// } else if (state.openInReaderState == OpenInReaderState.error) {
// context.showSnackBar(
// localizations.openBookExternallyFailed,
// isError: true,
// );
// }
// },
// builder: (context, state) {
// final isLoading = state.status == BookDetailsStatus.loading;
// final hasError = state.status == BookDetailsStatus.error;
// if (hasError) {
// return Scaffold(
// appBar: AppBar(title: Text(localizations.error)),
// body: Center(
// child: Column(
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// Text(
// '${localizations.errorLoadingData}: ${state.errorMessage}',
// ),
// const SizedBox(height: 16),
// ElevatedButton(
// onPressed: () {
// context.read<BookDetailsBloc>().add(
// ReloadBookDetails(bookListModel, bookUuid),
// );
// },
// child: Text(localizations.tryAgain),
// ),
// ],
// ),
// ),
// );
// }
// final book = state.bookDetails ?? _createDummyBook(localizations);
// return Scaffold(
// appBar: AppBar(
// title:
// isLoading
// ? Skeletonizer(
// enabled: true,
// effect: ShimmerEffect(
// baseColor: Theme.of(
// context,
// ).colorScheme.primary.withValues(alpha: .2),
// highlightColor: Theme.of(
// context,
// ).colorScheme.primary.withValues(alpha: .4),
// ),
// child: Container(
// height: 20,
// width: 300,
// color: Colors.black,
// ),
// )
// : Text(
// book.title.length > 30
// ? "${book.title.substring(0, 30)}..."
// : book.title,
// ),
// leading: IconButton(
// onPressed: () => Navigator.of(context).pop(),
// icon: const Icon(Icons.arrow_back),
// ),
// ),
// body: Skeletonizer(
// enabled: isLoading,
// effect: ShimmerEffect(
// baseColor: Theme.of(
// context,
// ).colorScheme.primary.withValues(alpha: .2),
// highlightColor: Theme.of(
// context,
// ).colorScheme.primary.withValues(alpha: .4),
// ),
// child: _buildBookDetails(
// context,
// localizations,
// state,
// book,
// isLoading,
// ),
// ),
// floatingActionButton:
// isLoading ? null : SendToEreaderWidget(book: book),
// );
// },
// ),
// );
// }
// BookDetailsModel _createDummyBook(AppLocalizations localizations) {
// return BookDetailsModel(
// id: 0,
// uuid: 'dummy-uuid',
// title: localizations.loading,
// authors: 'Jane & John Doe',
// );
// }
// Widget _buildBookDetails(
// BuildContext context,
// AppLocalizations localizations,
// BookDetailsState state,
// BookDetailsModel book,
// bool isLoading,
// ) {
// return SingleChildScrollView(
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// // Cover image with gradient overlay
// Stack(
// alignment: Alignment.bottomLeft,
// children: [
// // Cover image
// _buildCoverImage(context, book.id, localizations),
// // Gradient overlay for better text visibility
// Container(
// decoration: BoxDecoration(
// gradient: LinearGradient(
// begin: Alignment.topCenter,
// end: Alignment.bottomCenter,
// colors: [
// Colors.transparent,
// Colors.black.withValues(alpha: .7),
// ],
// stops: const [0.5, 1.0],
// ),
// ),
// height: 100,
// width: double.infinity,
// ),
// // Title and Author overlay
// Padding(
// padding: const EdgeInsets.all(16.0),
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text(
// book.title,
// style: Theme.of(context).textTheme.titleLarge?.copyWith(
// color: Colors.white,
// fontWeight: FontWeight.bold,
// shadows: [
// Shadow(
// offset: const Offset(1, 1),
// blurRadius: 3,
// color: Colors.black.withValues(alpha: .5),
// ),
// ],
// ),
// ),
// const SizedBox(height: 4),
// Text(
// localizations.by(book.authors),
// style: Theme.of(context).textTheme.titleMedium?.copyWith(
// color: Colors.white.withValues(alpha: .9),
// shadows: [
// Shadow(
// offset: const Offset(1, 1),
// blurRadius: 2,
// color: Colors.black.withValues(alpha: .5),
// ),
// ],
// ),
// ),
// ],
// ),
// ),
// ],
// ),
// // Book Actions
// _buildCard(
// context,
// Icons.menu_book_rounded,
// localizations.bookActions,
// _buildBookActions(context, localizations, state, book, isLoading),
// ),
// // TODO: implement rating double value
// // Rating section
// // if (book.rating != null && book.rating! > 0)
// // _buildCard(
// // context,
// // Icons.star_rate_rounded,
// // localizations.rating,
// // Padding(
// // padding: const EdgeInsets.symmetric(vertical: 8.0),
// // child: _buildRating(book.rating!),
// // ),
// // ),
// // Series info if available
// if (book.series.isNotEmpty)
// _buildCard(
// context,
// Icons.bookmark_rounded,
// localizations.series,
// InkWell(
// borderRadius: BorderRadius.circular(8.0),
// onTap: () {
// // TODO: implement sieres page
// // Navigator.of(context).push(
// // AppTransitions.createSlideRoute(
// // BookListPage(
// // title: book.series!,
// // categoryType: CategoryType.series,
// // fullPath: "/opds/series/${book.seriesId}",
// // ),
// // ),
// // );
// },
// child: Container(
// padding: const EdgeInsets.symmetric(
// vertical: 4.0,
// horizontal: 4.0,
// ),
// child: Text(
// book.seriesIndex != null
// ? '${book.series} (${localizations.book} ${book.seriesIndex?.toInt()})'
// : book.series!,
// ),
// ),
// ),
// ),
// // Publication Info section
// _buildInfoCard(
// context,
// Icons.info_outline_rounded,
// localizations.publicationInfo,
// [
// if (book.published != null)
// _buildInfoRow(
// context,
// localizations.published,
// intl.DateFormat.yMMMMd(
// localizations.localeName,
// ).format(book.published!),
// Icons.calendar_today_rounded,
// ),
// if (book.updated != null && book.published != null)
// _buildInfoRow(
// context,
// localizations.updated,
// intl.DateFormat.yMMMMd(
// localizations.localeName,
// ).format(book.updated!),
// Icons.update_rounded,
// ),
// if (book.publisher != null && book.publisher!.isNotEmpty)
// _buildInfoRow(
// context,
// localizations.publisher,
// book.publisher!,
// Icons.business_rounded,
// ),
// if (book.languages.isNotEmpty)
// _buildInfoRow(
// context,
// localizations.language,
// _formatLanguage(book.languages, localizations),
// Icons.language_rounded,
// ),
// ],
// ),
// // File Info section
// _buildInfoCard(
// context,
// Icons.description_rounded,
// localizations.fileInfo,
// [
// if (book.formats.isNotEmpty)
// _buildInfoRow(
// context,
// localizations.formats,
// book.formats.join(', '),
// Icons.folder_rounded,
// ),
// if (book.fileSize != null)
// _buildInfoRow(
// context,
// localizations.size,
// _formatFileSize(book.fileSize!),
// Icons.data_usage_rounded,
// ),
// _buildInfoRow(context, 'ID', book.uuid, Icons.tag_rounded),
// ],
// ),
// // Tags section
// if (book.tags.isNotEmpty)
// _buildCard(
// context,
// Icons.local_offer_rounded,
// localizations.categories,
// Padding(
// padding: const EdgeInsets.symmetric(vertical: 8.0),
// child: _buildTags(context, book.tags, book.categoriesMap),
// ),
// ),
// // Description section
// if (book.comments.isNotEmpty)
// _buildCard(
// context,
// Icons.article_rounded,
// localizations.description,
// Padding(
// padding: const EdgeInsets.symmetric(vertical: 8.0),
// child: Text(
// book.comments,
// style: Theme.of(context).textTheme.bodyMedium,
// ),
// ),
// ),
// // Bottom padding
// const SizedBox(height: 16),
// ],
// ),
// );
// }
// Widget _buildBookActions(
// BuildContext context,
// AppLocalizations localizations,
// BookDetailsState state,
// BookDetailsModel book,
// bool isLoading,
// ) {
// return SingleChildScrollView(
// scrollDirection: Axis.horizontal,
// child: Row(
// mainAxisAlignment: MainAxisAlignment.spaceEvenly,
// children: [
// // Read/Unread toggle
// IconButton(
// icon: CircleAvatar(
// backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
// child:
// state.readStatusState == ReadStatusState.loading
// ? SizedBox(
// width: 20,
// height: 20,
// child: CircularProgressIndicator(strokeWidth: 3),
// )
// : Icon(
// state.isBookRead
// ? Icons.visibility
// : Icons.visibility_off,
// ),
// ),
// onPressed:
// isLoading
// ? null
// : () => context.read<BookDetailsBloc>().add(
// ToggleReadStatus(book.id),
// ),
// tooltip: localizations.markAsReadUnread,
// ),
// // Archive/Unarchive toggle
// IconButton(
// icon: CircleAvatar(
// backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
// child:
// state.archiveStatusState == ArchiveStatusState.loading
// ? SizedBox(
// width: 20,
// height: 20,
// child: CircularProgressIndicator(strokeWidth: 3),
// )
// : Icon(
// state.isBookArchived ? Icons.archive : Icons.unarchive,
// ),
// ),
// onPressed:
// isLoading
// ? null
// : () => context.read<BookDetailsBloc>().add(
// ToggleArchiveStatus(book.id),
// ),
// tooltip: localizations.archiveUnarchive,
// ),
// EditBookMetadataWidget(book: book, isLoading: isLoading),
// AddToShelfWidget(book: book, isLoading: isLoading),
// DownloadToDeviceWidget(book: book, isLoading: isLoading),
// // Open in reader button
// IconButton(
// icon: CircleAvatar(
// backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
// child:
// state.openInReaderState == OpenInReaderState.loading
// ? SizedBox(
// width: 20,
// height: 20,
// child: CircularProgressIndicator(strokeWidth: 3),
// )
// : Icon(Icons.open_in_new_rounded),
// ),
// onPressed:
// isLoading
// ? null
// : () async {
// final settings =
// SettingsEntity(); // Retrieve from provider
// final String? selectedDirectory;
// if (settings.defaultDownloadPath.isEmpty) {
// selectedDirectory =
// await FilePicker.platform.getDirectoryPath();
// if (selectedDirectory == null) {
// return;
// }
// } else {
// selectedDirectory = settings.defaultDownloadPath;
// }
// // ignore: use_build_context_synchronously
// context.read<BookDetailsBloc>().add(
// OpenBookInReader(
// selectedDirectory: selectedDirectory,
// schema: settings.downloadSchema,
// ),
// );
// },
// tooltip: localizations.openInReader,
// ),
// // Open in browser button
// IconButton(
// icon: CircleAvatar(
// backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
// child: Icon(Icons.open_in_browser_rounded),
// ),
// onPressed:
// () => context.read<BookDetailsBloc>().add(OpenBookInBrowser()),
// tooltip: localizations.openBookInBrowser,
// ),
// ],
// ),
// );
// }
// Widget _buildCard(
// BuildContext context,
// IconData icon,
// String title,
// Widget child,
// ) {
// BorderRadius borderRadius = BorderRadius.circular(12.0);
// return Card(
// margin: const EdgeInsets.fromLTRB(16, 12, 16, 4),
// elevation: 2,
// shape: RoundedRectangleBorder(borderRadius: borderRadius),
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// // Card header with icon and title
// Padding(
// padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
// child: Row(
// children: [
// Icon(
// icon,
// size: 24,
// color: Theme.of(context).colorScheme.primary,
// ),
// const SizedBox(width: 12),
// Text(
// title,
// style: Theme.of(context).textTheme.titleMedium?.copyWith(
// fontWeight: FontWeight.bold,
// ),
// ),
// ],
// ),
// ),
// // Divider
// const Divider(height: 4),
// // Card content
// Padding(
// padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
// child: child,
// ),
// ],
// ),
// );
// }
// Widget _buildInfoCard(
// BuildContext context,
// IconData icon,
// String title,
// List<Widget> children,
// ) {
// final validChildren = children.where((w) => w is! SizedBox).toList();
// if (validChildren.isEmpty) return const SizedBox.shrink();
// return _buildCard(
// context,
// icon,
// title,
// Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: validChildren,
// ),
// );
// }
// Widget _buildInfoRow(
// BuildContext context,
// String label,
// String value, [
// IconData? icon,
// ]) {
// if (value.isEmpty) return const SizedBox.shrink();
// return Padding(
// padding: const EdgeInsets.symmetric(vertical: 6),
// child: Row(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// if (icon != null) ...[
// Icon(
// icon,
// size: 18,
// color: Theme.of(
// context,
// ).colorScheme.secondary.withValues(alpha: .7),
// ),
// const SizedBox(width: 8),
// ],
// SizedBox(
// width: 80,
// child: Text(
// '$label:',
// style: TextStyle(
// fontWeight: FontWeight.bold,
// color: Theme.of(context).colorScheme.secondary,
// ),
// ),
// ),
// Expanded(
// child: Text(value, style: Theme.of(context).textTheme.bodyMedium),
// ),
// ],
// ),
// );
// }
// String _formatLanguage(String languageCode, AppLocalizations localizations) {
// var languageMap = {
// 'eng': localizations.english,
// 'deu': localizations.german,
// 'fra': localizations.french,
// 'spa': localizations.spanish,
// 'ita': localizations.italian,
// 'jpn': localizations.japanese,
// 'rus': localizations.russian,
// 'por': localizations.portuguese,
// 'chi': localizations.chineese,
// 'nld': localizations.dutch,
// };
// return languageMap[languageCode.toLowerCase()] ?? languageCode;
// }
// String _formatFileSize(int sizeInBytes) {
// if (sizeInBytes < 1024) return '$sizeInBytes B';
// if (sizeInBytes < 1024 * 1024) {
// return '${(sizeInBytes / 1024).toStringAsFixed(1)} KB';
// }
// return '${(sizeInBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
// }
// Widget _buildCoverImage(
// BuildContext context,
// int bookId,
// AppLocalizations localizations,
// ) {
// ApiService apiService = ApiService();
// final baseUrl = apiService.getBaseUrl();
// final username = apiService.getUsername();
// final password = apiService.getPassword();
// final authHeader =
// 'Basic ${base64.encode(utf8.encode('$username:$password'))}';
// final coverUrl = '$baseUrl/opds/cover/$bookId';
// return SizedBox(
// height: 300,
// width: double.infinity,
// child: CachedNetworkImage(
// imageUrl: coverUrl,
// httpHeaders: {'Authorization': authHeader},
// fit: BoxFit.cover,
// placeholder:
// (context, url) => Container(
// color: Theme.of(
// context,
// ).colorScheme.surfaceContainerHighest.withValues(alpha: .3),
// child: Skeletonizer(
// enabled: true,
// effect: ShimmerEffect(
// baseColor: Theme.of(
// context,
// ).colorScheme.primary.withValues(alpha: .2),
// highlightColor: Theme.of(
// context,
// ).colorScheme.primary.withValues(alpha: .4),
// ),
// child: SizedBox(),
// ),
// ),
// errorWidget:
// (context, url, error) => Container(
// color: Theme.of(context).colorScheme.surfaceContainerHighest,
// child: Center(
// child: Column(
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// Icon(
// Icons.book,
// size: 64,
// color: Theme.of(context).colorScheme.primary,
// ),
// const SizedBox(height: 8),
// Text(
// localizations.noCoverAvailable,
// style: TextStyle(
// color: Theme.of(context).colorScheme.onSurfaceVariant,
// ),
// ),
// ],
// ),
// ),
// ),
// memCacheWidth: 600,
// memCacheHeight: 900,
// ),
// );
// }
// Widget _buildTags(
// BuildContext context,
// List<String> tags,
// Map<String, dynamic> tagsMap,
// ) {
// return SingleChildScrollView(
// scrollDirection: Axis.horizontal,
// child: Row(
// children:
// tags.map((tag) {
// final categoryId = tagsMap[tag];
// return Padding(
// padding: const EdgeInsets.only(right: 8.0),
// child: InkWell(
// borderRadius: BorderRadius.circular(8.0),
// onTap: () {
// // TODO: implement tag navigation
// // Navigator.of(context).push(
// // AppTransitions.createSlideRoute(
// // BookListPage(
// // title: tag,
// // categoryType: CategoryType.category,
// // fullPath: "/opds/category/$categoryId",
// // ),
// // ),
// // );
// },
// child: Chip(
// label: Text(tag),
// backgroundColor:
// Theme.of(context).colorScheme.secondaryContainer,
// labelStyle: TextStyle(
// color: Theme.of(context).colorScheme.onSecondaryContainer,
// ),
// visualDensity: VisualDensity.compact,
// ),
// ),
// );
// }).toList(),
// ),
// );
// }
// Widget _buildRating(double rating) {
// final int filledStars = rating.floor();
// final bool hasHalfStar = (rating - filledStars) >= 0.5;
// final int maxStars = 10;
// return Row(
// mainAxisSize: MainAxisSize.min,
// children: [
// for (int i = 0; i < maxStars; i++)
// Icon(
// i < filledStars
// ? Icons.star
// : (i == filledStars && hasHalfStar)
// ? Icons.star_half
// : Icons.star_border,
// color: Colors.amber,
// size: 20,
// ),
// const SizedBox(width: 8),
// Text(
// '${rating.toStringAsFixed(1)} / $maxStars',
// style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
// ),
// ],
// );
// }
// }
@@ -0,0 +1,231 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:logger/logger.dart';
import 'package:calibre_web_companion/features/book_view/bloc/book_view_event.dart';
import 'package:calibre_web_companion/features/book_view/bloc/book_view_state.dart';
import 'package:calibre_web_companion/features/book_view/data/repositories/book_view_repository.dart';
class BookViewBloc extends Bloc<BookViewEvent, BookViewState> {
final BookViewRepository _repository;
final Logger _logger;
BookViewBloc({required BookViewRepository repository, Logger? logger})
: _repository = repository,
_logger = logger ?? Logger(),
super(const BookViewState()) {
on<LoadSettings>(_onLoadSettings);
on<LoadBooks>(_onLoadBooks);
on<LoadMoreBooks>(_onLoadMoreBooks);
on<RefreshBooks>(_onRefreshBooks);
on<ChangeSort>(_onChangeSort);
on<SearchBooks>(_onSearchBooks);
on<UploadBook>(_onUploadBook);
on<ChangeColumnCount>(_onChangeColumnCount);
on<UploadCancel>(_onUploadCancel);
}
Future<void> _onLoadSettings(
LoadSettings event,
Emitter<BookViewState> emit,
) async {
try {
final columnCount = await _repository.getColumnCount();
emit(state.copyWith(columnCount: columnCount));
} catch (e) {
_logger.e('Error loading settings: $e');
}
}
Future<void> _onLoadBooks(
LoadBooks event,
Emitter<BookViewState> emit,
) async {
if (state.isLoading) return;
emit(state.copyWith(isLoading: true, hasError: false, errorMessage: ''));
try {
final books = await _repository.fetchBooks(
offset: state.offset,
limit: state.limit,
searchQuery: state.searchQuery,
sortBy: state.sortBy,
sortOrder: state.sortOrder,
);
final hasMoreBooks = books.length == state.limit;
// Special case for authors sorting which has pagination issues
final adjustedHasMoreBooks =
state.sortBy == 'authors' ? true : hasMoreBooks;
emit(
state.copyWith(
books: books,
isLoading: false,
hasMoreBooks: adjustedHasMoreBooks,
offset: state.offset + books.length,
),
);
} catch (e) {
_logger.e('Error loading books: $e');
emit(
state.copyWith(
isLoading: false,
hasError: true,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onLoadMoreBooks(
LoadMoreBooks event,
Emitter<BookViewState> emit,
) async {
if (state.isLoading || !state.hasMoreBooks) return;
emit(state.copyWith(isLoading: true));
try {
final moreBooks = await _repository.fetchBooks(
offset: state.offset,
limit: state.limit,
searchQuery: state.searchQuery,
sortBy: state.sortBy,
sortOrder: state.sortOrder,
);
final allBooks = [...state.books, ...moreBooks];
final hasMoreBooks = moreBooks.length == state.limit;
// Special case for authors sorting
final adjustedHasMoreBooks =
state.sortBy == 'authors' ? true : hasMoreBooks;
emit(
state.copyWith(
books: allBooks,
isLoading: false,
hasMoreBooks: adjustedHasMoreBooks,
offset: state.offset + moreBooks.length,
),
);
} catch (e) {
_logger.e('Error loading more books: $e');
emit(
state.copyWith(
isLoading: false,
hasError: true,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onRefreshBooks(
RefreshBooks event,
Emitter<BookViewState> emit,
) async {
emit(
state.copyWith(
offset: 0,
books: [],
hasMoreBooks: true,
isLoading: false,
hasError: false,
errorMessage: '',
),
);
add(const LoadBooks());
}
Future<void> _onChangeSort(
ChangeSort event,
Emitter<BookViewState> emit,
) async {
_logger.i('Sorting by ${event.sortBy} ${event.sortOrder}');
emit(
state.copyWith(
sortBy: event.sortBy,
sortOrder: event.sortOrder,
offset: 0,
books: [],
hasMoreBooks: true,
),
);
add(const LoadBooks());
}
Future<void> _onSearchBooks(
SearchBooks event,
Emitter<BookViewState> emit,
) async {
emit(
state.copyWith(
searchQuery: event.query,
offset: 0,
books: [],
hasMoreBooks: true,
),
);
add(const LoadBooks());
}
Future<void> _onUploadBook(
UploadBook event,
Emitter<BookViewState> emit,
) async {
emit(
state.copyWith(
uploadStatus: UploadStatus.loading,
hasError: false,
errorMessage: '',
),
);
try {
emit(state.copyWith(uploadStatus: UploadStatus.uploading));
final result = await _repository.uploadEbook(event.book);
emit(
state.copyWith(
uploadStatus: result ? UploadStatus.success : UploadStatus.failed,
),
);
if (result) {
add(const RefreshBooks());
}
} catch (e) {
_logger.e('Error uploading book: $e');
emit(
state.copyWith(
uploadStatus: UploadStatus.failed,
hasError: true,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onChangeColumnCount(
ChangeColumnCount event,
Emitter<BookViewState> emit,
) async {
try {
await _repository.setColumnCount(event.count);
emit(state.copyWith(columnCount: event.count));
} catch (e) {
_logger.e('Error changing column count: $e');
}
}
void _onUploadCancel(UploadCancel event, Emitter<BookViewState> emit) {
emit(state.copyWith(uploadStatus: UploadStatus.initial));
}
}
@@ -0,0 +1,67 @@
import 'dart:io';
import 'package:equatable/equatable.dart';
abstract class BookViewEvent extends Equatable {
const BookViewEvent();
@override
List<Object?> get props => [];
}
class LoadBooks extends BookViewEvent {
const LoadBooks();
}
class LoadMoreBooks extends BookViewEvent {
const LoadMoreBooks();
}
class RefreshBooks extends BookViewEvent {
const RefreshBooks();
}
class ChangeSort extends BookViewEvent {
final String sortBy;
final String sortOrder;
const ChangeSort({required this.sortBy, required this.sortOrder});
@override
List<Object?> get props => [sortBy, sortOrder];
}
class SearchBooks extends BookViewEvent {
final String? query;
const SearchBooks(this.query);
@override
List<Object?> get props => [query];
}
class UploadBook extends BookViewEvent {
final File book;
const UploadBook(this.book);
@override
List<Object?> get props => [book];
}
class ChangeColumnCount extends BookViewEvent {
final int count;
const ChangeColumnCount(this.count);
@override
List<Object?> get props => [count];
}
class LoadSettings extends BookViewEvent {
const LoadSettings();
}
class UploadCancel extends BookViewEvent {
const UploadCancel();
}
@@ -0,0 +1,81 @@
import 'package:equatable/equatable.dart';
import 'package:calibre_web_companion/features/book_view/data/models/book_view_model.dart';
enum UploadStatus { initial, loading, uploading, success, failed }
class BookViewState extends Equatable {
final List<BookViewModel> books;
final bool isLoading;
final bool hasError;
final String errorMessage;
final bool hasMoreBooks;
final int offset;
final int limit;
final String sortBy;
final String sortOrder;
final String? searchQuery;
final int columnCount;
final UploadStatus uploadStatus;
const BookViewState({
this.books = const [],
this.isLoading = false,
this.hasError = false,
this.errorMessage = '',
this.hasMoreBooks = true,
this.offset = 0,
this.limit = 20,
this.sortBy = '',
this.sortOrder = '',
this.searchQuery,
this.columnCount = 2,
this.uploadStatus = UploadStatus.initial,
});
BookViewState copyWith({
List<BookViewModel>? books,
bool? isLoading,
bool? hasError,
String? errorMessage,
bool? hasMoreBooks,
int? offset,
int? limit,
String? sortBy,
String? sortOrder,
String? searchQuery,
int? columnCount,
UploadStatus? uploadStatus,
}) {
return BookViewState(
books: books ?? this.books,
isLoading: isLoading ?? this.isLoading,
hasError: hasError ?? this.hasError,
errorMessage: errorMessage ?? this.errorMessage,
hasMoreBooks: hasMoreBooks ?? this.hasMoreBooks,
offset: offset ?? this.offset,
limit: limit ?? this.limit,
sortBy: sortBy ?? this.sortBy,
sortOrder: sortOrder ?? this.sortOrder,
searchQuery: searchQuery ?? this.searchQuery,
columnCount: columnCount ?? this.columnCount,
uploadStatus: uploadStatus ?? this.uploadStatus,
);
}
@override
List<Object?> get props => [
books,
isLoading,
hasError,
errorMessage,
hasMoreBooks,
offset,
limit,
sortBy,
sortOrder,
searchQuery,
columnCount,
uploadStatus,
];
}
@@ -0,0 +1,127 @@
import 'dart:async';
import 'dart:io';
import 'package:logger/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:calibre_web_companion/core/services/api_service.dart';
import 'package:calibre_web_companion/core/services/json_service.dart';
import 'package:calibre_web_companion/features/book_view/data/models/book_view_model.dart';
class CancellationToken {
bool _isCancelled = false;
void cancel() => _isCancelled = true;
bool get isCancelled => _isCancelled;
}
class BookViewDatasource {
final ApiService _apiService;
final Logger _logger;
final SharedPreferences _preferences;
BookViewDatasource({
required SharedPreferences preferences,
JsonService? jsonService,
ApiService? apiService,
Logger? logger,
}) : _preferences = preferences,
_apiService = apiService ?? ApiService(),
_logger = logger ?? Logger();
Future<List<BookViewModel>> fetchBooks({
required int offset,
required int limit,
String? searchQuery,
String sortBy = '',
String sortOrder = '',
}) async {
try {
List<BookViewModel> books = [];
final queryParams = {
'offset': offset.toString(),
'limit': limit.toString(),
'sort': sortBy,
'order': sortOrder,
};
if (searchQuery != null && searchQuery.isNotEmpty) {
queryParams['search'] = searchQuery;
}
final response = await _apiService.getJson(
'/ajax/listbooks',
AuthMethod.cookie,
queryParams: queryParams,
);
_logger.i(response);
if (response.containsKey('rows') && response['rows'] is List) {
final List<dynamic> rows = response['rows'];
if (rows.isEmpty) {
_logger.i('Received empty book list');
return books;
}
for (var bookData in rows) {
try {
final book = BookViewModel.fromJson(bookData);
books.add(book);
_logger.i(book.toJson());
} catch (e) {
_logger.e('Error parsing book: $e');
}
}
_logger.i('Parsed ${books.length} books');
return books;
}
throw Exception('Invalid response format: $response');
} catch (e) {
_logger.e('Error fetching books: $e');
throw Exception('Failed to load books: $e');
}
}
Future<bool> uploadEbook(File book, CancellationToken cancelToken) async {
try {
final result = await _apiService.uploadFile(
book,
'/upload',
cancelToken: cancelToken,
timeoutSeconds: 60,
);
if (result['cancelled'] == true) {
_logger.i('Upload was cancelled');
return false;
}
if (result['success'] == true) {
return true;
} else {
_logger.e('Upload failed: ${result['error']}');
throw Exception(result['error']);
}
} catch (e) {
_logger.e('Error uploading book: $e');
if (!cancelToken.isCancelled) {
throw Exception('Upload error: $e');
}
return false;
}
}
// Column count preferences
Future<int> getColumnCount() async {
return _preferences.getInt('grid_column_count') ?? 2;
}
/// Sets the number of columns for the book grid view.
Future<void> setColumnCount(int count) async {
if (count < 1) count = 1;
if (count > 5) count = 5;
await _preferences.setInt('grid_column_count', count);
}
}
@@ -0,0 +1,164 @@
import 'package:equatable/equatable.dart';
import 'package:logger/logger.dart';
class BookViewModel extends Equatable {
final String authorSort;
final String authors;
final String comments;
final String data;
final bool flags; // 1 = true, 0 = false
final bool hasCover; // 1 = true, 0 = false
final int id;
final String identifiers;
final bool isArchived; // 1 = true, 0 = false
final String isbn;
final String languages;
final String lastModified;
final String path;
final String pubdate;
final String publishers;
final String ratings;
final bool readStatus; // "true" or "false"
final String registry;
final String series;
final double seriesIndex;
final String sort;
final List<String> tags;
final String timestamp;
final String title;
final String uuid;
static final Logger _logger = Logger();
const BookViewModel({
required this.id,
required this.uuid,
required this.title,
required this.authors,
this.authorSort = '',
this.comments = '',
this.data = '',
this.flags = false,
this.hasCover = false,
this.identifiers = '',
this.isArchived = false,
this.isbn = '',
this.languages = '',
this.lastModified = '',
this.path = '',
this.pubdate = '',
this.publishers = '',
this.ratings = '',
this.readStatus = false,
this.registry = '',
this.series = '',
this.seriesIndex = 0.0,
this.sort = '',
this.tags = const [],
this.timestamp = '',
});
@override
List<Object?> get props => [
id,
uuid,
title,
authors,
authorSort,
comments,
data,
flags,
hasCover,
identifiers,
isArchived,
isbn,
languages,
lastModified,
path,
pubdate,
publishers,
ratings,
readStatus,
registry,
series,
seriesIndex,
sort,
tags,
timestamp,
];
/// Converts the BookItem to a JSON map
Map<String, dynamic> toJson() {
return {
'id': id,
'uuid': uuid,
'title': title,
'authors': authors,
'author_sort': authorSort,
'description': comments,
'data': data,
'flags': flags,
'has_cover': hasCover,
'identifiers': identifiers,
'archived': isArchived,
'isbn': isbn,
'languages': languages,
'last_modified': lastModified,
'path': path,
'pubdate': pubdate,
'publisher_name': publishers,
'ratings': ratings,
'read_status': readStatus,
'registry': registry,
'series': series,
'series_index': seriesIndex.toString(),
'sort': sort,
'tags': tags.join(','),
'timestamp': timestamp,
};
}
/// Factory method to create a BookItem from JSON
factory BookViewModel.fromJson(Map<String, dynamic> json) {
try {
return BookViewModel(
id: json['id'],
uuid: json['uuid'],
title: json['title'],
authors: json['authors'],
authorSort: json['author_sort'],
comments: json['comments'],
data: json['data'],
flags: json['flags'] == 1,
hasCover: json['has_cover'] == 1,
identifiers: json['identifiers'],
isArchived: json['archived'] == 1,
isbn: json['isbn'],
languages: json['languages'],
lastModified: json['last_modified'],
path: json['path'],
pubdate: json['pubdate'],
publishers: json['publishers'],
ratings: json['ratings'],
readStatus: json['read_status'] == 'true',
registry: json['registry'],
series: json['series'],
seriesIndex: json['series_index'],
sort: json['sort'],
tags:
(() {
final tagsJson = json['tags'];
if (tagsJson is List) {
return tagsJson.map((tag) => tag.toString()).toList();
} else {
return <String>[];
}
})(),
timestamp: json['timestamp'],
);
} catch (e) {
_logger.e('Error creating BookItem from JSON: $e');
throw FormatException('Failed to parse book data: $e');
}
}
}
@@ -0,0 +1,53 @@
import 'dart:io';
import 'package:logger/logger.dart';
import 'package:calibre_web_companion/features/book_view/data/datasources/book_view_datasource.dart';
import 'package:calibre_web_companion/features/book_view/data/models/book_view_model.dart';
class BookViewRepository {
final BookViewDatasource _datasource;
final Logger _logger;
BookViewRepository({required BookViewDatasource datasource, Logger? logger})
: _datasource = datasource,
_logger = logger ?? Logger();
Future<List<BookViewModel>> fetchBooks({
required int offset,
required int limit,
String? searchQuery,
String sortBy = '',
String sortOrder = '',
}) async {
try {
return await _datasource.fetchBooks(
offset: offset,
limit: limit,
searchQuery: searchQuery,
sortBy: sortBy,
sortOrder: sortOrder,
);
} catch (e) {
_logger.e('Repository error fetching books: $e');
rethrow;
}
}
Future<bool> uploadEbook(File book) async {
try {
final cancelToken = CancellationToken();
return await _datasource.uploadEbook(book, cancelToken);
} catch (e) {
_logger.e('Repository error uploading book: $e');
rethrow;
}
}
Future<int> getColumnCount() async {
return await _datasource.getColumnCount();
}
Future<void> setColumnCount(int count) async {
await _datasource.setColumnCount(count);
}
}
@@ -0,0 +1,487 @@
import 'dart:io';
import 'package:calibre_web_companion/core/services/snackbar.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:calibre_web_companion/features/book_view/bloc/book_view_bloc.dart';
import 'package:calibre_web_companion/features/book_view/bloc/book_view_event.dart';
import 'package:calibre_web_companion/features/book_view/bloc/book_view_state.dart';
import 'package:calibre_web_companion/features/book_view/presentation/widgets/book_card.dart';
import 'package:calibre_web_companion/features/book_view/presentation/widgets/book_skeleton.dart';
import 'package:calibre_web_companion/features/book_view/presentation/widgets/search_dialog.dart';
class BookViewPage extends StatefulWidget {
const BookViewPage({super.key});
@override
State<BookViewPage> createState() => _BookViewPageState();
}
class _BookViewPageState extends State<BookViewPage> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_scrollListener);
// Load settings and initial books
context.read<BookViewBloc>()
..add(const LoadSettings())
..add(const LoadBooks());
}
@override
void dispose() {
_scrollController.removeListener(_scrollListener);
_scrollController.dispose();
super.dispose();
}
void _scrollListener() {
final bloc = context.read<BookViewBloc>();
final state = bloc.state;
if (!state.isLoading &&
state.hasMoreBooks &&
_scrollController.position.pixels >
_scrollController.position.maxScrollExtent - 500) {
bloc.add(const LoadMoreBooks());
}
}
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
return BlocConsumer<BookViewBloc, BookViewState>(
listenWhen:
(previous, current) =>
previous.uploadStatus != current.uploadStatus ||
(current.hasError && !previous.hasError),
listener: (context, state) {
if (state.uploadStatus == UploadStatus.loading ||
state.uploadStatus == UploadStatus.uploading) {
_showUploadStatusSheet(context, localizations);
}
if (state.hasError) {
context.showSnackBar(state.errorMessage, isError: true);
}
},
builder: (context, state) {
return Scaffold(
appBar: AppBar(
title: Text(localizations.books),
actions: [
_buildColumnSelector(context, state, localizations),
_buildSortOptions(context, localizations),
_buildSearchButton(context),
],
),
body: _buildBody(context, state, localizations),
floatingActionButton: FloatingActionButton(
onPressed: () => _pickAndUploadBook(context, localizations),
tooltip: localizations.uploadEbook,
child: const Icon(Icons.upload_rounded),
),
);
},
);
}
Widget _buildBody(
BuildContext context,
BookViewState state,
AppLocalizations localizations,
) {
if (state.books.isEmpty && state.isLoading) {
return _buildBookGridSkeletons(state);
}
if (state.books.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.menu_book_outlined,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
localizations.noBooksFound,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
icon: const Icon(Icons.refresh),
label: Text(localizations.books),
onPressed: () {
context.read<BookViewBloc>().add(const LoadBooks());
},
),
],
),
);
}
return RefreshIndicator(
onRefresh: () {
context.read<BookViewBloc>().add(const RefreshBooks());
return Future.value();
},
child: GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16.0),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: state.columnCount,
childAspectRatio: state.columnCount <= 2 ? 0.7 : 0.9,
crossAxisSpacing: 16.0,
mainAxisSpacing: 16.0,
),
itemCount:
state.hasMoreBooks ? state.books.length + 1 : state.books.length,
itemBuilder: (context, index) {
if (index == state.books.length) {
return const BookCardSkeleton();
}
return BookCard(book: state.books[index]);
},
),
);
}
Widget _buildBookGridSkeletons(BookViewState state) {
final aspectRatio = state.columnCount <= 2 ? 0.7 : 0.9;
return GridView.builder(
padding: const EdgeInsets.all(16.0),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: state.columnCount,
childAspectRatio: aspectRatio,
crossAxisSpacing: 16.0,
mainAxisSpacing: 16.0,
),
itemCount: 10,
itemBuilder: (context, index) {
return const BookCardSkeleton();
},
);
}
Widget _buildColumnSelector(
BuildContext context,
BookViewState state,
AppLocalizations localizations,
) {
return PopupMenuButton<int>(
icon: const Icon(Icons.grid_view_rounded),
tooltip: localizations.columnsCount,
onSelected: (int value) {
context.read<BookViewBloc>().add(ChangeColumnCount(value));
},
itemBuilder:
(context) => [
for (int i = 1; i <= 5; i++)
PopupMenuItem<int>(
value: i,
child: Row(
children: [
Icon(
i == 1
? Icons.looks_one
: i == 2
? Icons.looks_two
: i == 3
? Icons.looks_3
: i == 4
? Icons.looks_4
: Icons.looks_5,
color:
state.columnCount == i
? Theme.of(context).colorScheme.primary
: null,
),
const SizedBox(width: 8),
Text('$i ${localizations.columns}'),
],
),
),
],
);
}
Widget _buildSortOptions(
BuildContext context,
AppLocalizations localizations,
) {
return PopupMenuButton<String>(
icon: const Icon(Icons.sort),
onSelected: (String value) {
final sortParts = value.split(':');
if (sortParts.length == 2) {
context.read<BookViewBloc>().add(
ChangeSort(sortBy: sortParts[0], sortOrder: sortParts[1]),
);
}
},
itemBuilder:
(BuildContext context) => [
PopupMenuItem(
value: 'title:asc',
child: Text(localizations.titleAZ),
),
PopupMenuItem(
value: 'title:desc',
child: Text(localizations.titleZA),
),
PopupMenuItem(
value: 'authors:asc',
child: Text(localizations.authorAZ),
),
PopupMenuItem(
value: 'authors:desc',
child: Text(localizations.authorZA),
),
PopupMenuItem(
value: 'added:desc',
child: Text(localizations.newestFirst),
),
],
);
}
Widget _buildSearchButton(BuildContext context) {
return IconButton(
icon: const Icon(Icons.search),
onPressed: () async {
final searchQuery = await showDialog<String>(
context: context,
builder: (context) => const SearchDialog(),
);
if (searchQuery != null) {
if (!context.mounted) return;
context.read<BookViewBloc>().add(SearchBooks(searchQuery));
}
},
);
}
Future<void> _pickAndUploadBook(
BuildContext context,
AppLocalizations localizations,
) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['epub', 'mobi', 'pdf'],
allowMultiple: false,
);
if (result == null || result.files.isEmpty) {
if (!context.mounted) return;
context.showSnackBar(localizations.noFilesSelected, isError: true);
return;
}
final file = File(result.files.single.path!);
if (!context.mounted) return;
context.read<BookViewBloc>().add(UploadBook(file));
}
void _showUploadStatusSheet(
BuildContext context,
AppLocalizations localizations,
) {
showModalBottomSheet(
context: context,
isDismissible: false,
enableDrag: false,
barrierColor: Colors.black54,
builder: (BuildContext context) {
return PopScope(
canPop: false,
child: BlocBuilder<BookViewBloc, BookViewState>(
buildWhen:
(previous, current) =>
previous.uploadStatus != current.uploadStatus ||
previous.errorMessage != current.errorMessage,
builder: (context, state) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Status icon
_buildStatusIcon(state.uploadStatus),
const SizedBox(height: 20),
// Status text
Text(
_getStatusMessage(state.uploadStatus, localizations),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
// Error message if available
if (state.hasError &&
state.uploadStatus == UploadStatus.failed)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
state.errorMessage,
style: TextStyle(
color: Colors.red[800],
fontSize: 12,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
),
const SizedBox(height: 20),
// Progress indicator for loading states
if (state.uploadStatus == UploadStatus.loading ||
state.uploadStatus == UploadStatus.uploading)
LinearProgressIndicator(
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).primaryColor,
),
),
const SizedBox(height: 20),
// Close/Cancel button
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
child: Material(
color:
Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12.0),
child: InkWell(
borderRadius: BorderRadius.circular(12.0),
onTap: () {
// If operation is in progress, call cancellation
if (state.uploadStatus == UploadStatus.loading ||
state.uploadStatus ==
UploadStatus.uploading) {
context.read<BookViewBloc>().add(
const UploadCancel(),
);
}
// Close the sheet
Navigator.of(context).pop();
},
child: Container(
width: double.infinity,
height: 50,
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
state.uploadStatus ==
UploadStatus.success ||
state.uploadStatus ==
UploadStatus.failed
? Icons.close
: Icons.cancel_rounded,
color:
Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
const SizedBox(width: 12),
Text(
state.uploadStatus ==
UploadStatus.success ||
state.uploadStatus ==
UploadStatus.failed
? localizations.close
: localizations.cancel,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color:
Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
),
],
),
),
),
),
),
],
),
),
);
},
),
);
},
);
}
Widget _buildStatusIcon(UploadStatus status) {
switch (status) {
case UploadStatus.loading:
return const CircularProgressIndicator();
case UploadStatus.uploading:
return const Icon(Icons.upload_rounded, size: 48);
case UploadStatus.success:
return Icon(
Icons.check_circle,
size: 48,
color: Theme.of(context).colorScheme.primary,
);
case UploadStatus.failed:
return Icon(
Icons.error_outline,
size: 48,
color: Theme.of(context).colorScheme.error,
);
default:
return const SizedBox();
}
}
String _getStatusMessage(
UploadStatus status,
AppLocalizations localizations,
) {
switch (status) {
case UploadStatus.loading:
return localizations.preparingUpload;
case UploadStatus.uploading:
return localizations.uploadingBook;
case UploadStatus.success:
return localizations.successfullySentToEReader;
case UploadStatus.failed:
return localizations.uploadFailed;
default:
return '';
}
}
}
@@ -0,0 +1,107 @@
import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:calibre_web_companion/core/services/api_service.dart';
import 'package:calibre_web_companion/features/book_view/data/models/book_view_model.dart';
class BookCard extends StatelessWidget {
final BookViewModel book;
const BookCard({super.key, required this.book});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4.0,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
// Navigator.of(context).push(
// AppTransitions.createSlideRoute(BookDetails(bookUuid: book.uuid)),
// );
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _buildCoverImage(context, book.id)),
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 4),
Text(
book.authors,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
],
),
),
],
),
),
);
}
/// Build the cover image
///
/// Parameters:
///
/// - `context`: BuildContext
/// - `bookId`: String
Widget _buildCoverImage(BuildContext context, int bookId) {
ApiService apiService = ApiService();
final baseUrl = apiService.getBaseUrl();
final username = apiService.getUsername();
final password = apiService.getPassword();
final authHeader =
'Basic ${base64.encode(utf8.encode('$username:$password'))}';
final coverUrl = '$baseUrl/opds/cover/$bookId';
return CachedNetworkImage(
imageUrl: coverUrl,
httpHeaders: {'Authorization': authHeader},
fit: BoxFit.cover,
width: double.infinity,
placeholder:
(context, url) => Container(
color: Theme.of(
context,
// ignore: deprecated_member_use
).colorScheme.surfaceContainerHighest.withOpacity(0.3),
child: Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(
context,
// ignore: deprecated_member_use
).colorScheme.primary.withOpacity(0.2),
highlightColor: Theme.of(
context,
// ignore: deprecated_member_use
).colorScheme.primary.withOpacity(0.4),
),
child: SizedBox(),
),
),
errorWidget: (context, url, error) => const SizedBox(),
memCacheWidth: 300,
memCacheHeight: 400,
);
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:calibre_web_companion/features/book_view/data/models/book_view_model.dart';
import 'package:calibre_web_companion/features/book_view/presentation/widgets/book_card.dart';
class BookCardSkeleton extends StatelessWidget {
const BookCardSkeleton({super.key});
@override
Widget build(BuildContext context) {
final dummyBook = BookViewModel(
id: 0,
uuid: 'skeleton-uuid',
title: 'Skeleton Book Title',
authors: 'Skeleton Author',
);
return Skeletonizer(
enabled: true,
effect: ShimmerEffect(
// ignore: deprecated_member_use
baseColor: Theme.of(context).colorScheme.primary.withOpacity(0.2),
// ignore: deprecated_member_use
highlightColor: Theme.of(context).colorScheme.primary.withOpacity(0.4),
),
child: BookCard(book: dummyBook),
);
}
}
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class SearchDialog extends StatefulWidget {
const SearchDialog({super.key});
@override
SearchDialogState createState() => SearchDialogState();
}
class SearchDialogState extends State<SearchDialog> {
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final AppLocalizations localizations = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(localizations.searchBook),
content: SizedBox(
width: double.maxFinite,
child: TextField(
controller: _controller,
decoration: InputDecoration(
labelText: localizations.enterTitleAuthorOrTags,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.search),
),
autofocus: true,
textInputAction: TextInputAction.search,
onSubmitted: (value) {
Navigator.of(context).pop(value);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(localizations.cancel),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop(_controller.text);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [Text(localizations.search)],
),
),
],
);
}
}
@@ -0,0 +1,27 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:calibre_web_companion/features/discover/blocs/discover_event.dart';
import 'package:calibre_web_companion/features/discover/blocs/discover_state.dart';
class DiscoverBloc extends Bloc<DiscoverEvent, DiscoverState> {
DiscoverBloc() : super(const DiscoverState()) {
on<NavigateToBookList>(_onNavigateToBookList);
on<NavigateToRecommendations>(_onNavigateToRecommendations);
}
void _onNavigateToBookList(
NavigateToBookList event,
Emitter<DiscoverState> emit,
) {
emit(state.copyWith(status: DiscoverStatus.navigating));
emit(state.copyWith(status: DiscoverStatus.initial));
}
void _onNavigateToRecommendations(
NavigateToRecommendations event,
Emitter<DiscoverState> emit,
) {
emit(state.copyWith(status: DiscoverStatus.navigating));
emit(state.copyWith(status: DiscoverStatus.initial));
}
}
@@ -0,0 +1,49 @@
import 'package:equatable/equatable.dart';
enum DiscoverType {
bookmarked,
unreadbooks,
readbooks,
hot,
newlyAdded,
rated,
discover,
}
enum CategoryType {
category,
language,
publisher,
author,
ratings,
formats,
series,
}
abstract class DiscoverEvent extends Equatable {
const DiscoverEvent();
@override
List<Object?> get props => [];
}
class NavigateToBookList extends DiscoverEvent {
final String title;
final CategoryType? categoryType;
final DiscoverType? discoverType;
final String? fullPath;
const NavigateToBookList({
required this.title,
this.categoryType,
this.discoverType,
this.fullPath,
});
@override
List<Object?> get props => [title, categoryType, discoverType, fullPath];
}
class NavigateToRecommendations extends DiscoverEvent {
const NavigateToRecommendations();
}
@@ -0,0 +1,16 @@
import 'package:equatable/equatable.dart';
enum DiscoverStatus { initial, navigating }
class DiscoverState extends Equatable {
final DiscoverStatus status;
const DiscoverState({this.status = DiscoverStatus.initial});
DiscoverState copyWith({DiscoverStatus? status}) {
return DiscoverState(status: status ?? this.status);
}
@override
List<Object?> get props => [status];
}
@@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:calibre_web_companion/features/discover/blocs/discover_bloc.dart';
import 'package:calibre_web_companion/features/discover/blocs/discover_event.dart';
import 'package:calibre_web_companion/features/discover/blocs/discover_state.dart';
import 'package:calibre_web_companion/core/services/app_transition.dart';
import 'package:calibre_web_companion/shared/widgets/long_button_widget.dart';
import 'package:calibre_web_companion/features/discover_details/presentation/pages/discover_details_page.dart';
class DiscoverPage extends StatelessWidget {
const DiscoverPage({super.key});
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
return BlocProvider(
create: (context) => DiscoverBloc(),
child: BlocBuilder<DiscoverBloc, DiscoverState>(
builder: (context, state) {
return Scaffold(
body: SingleChildScrollView(
child: Column(
children: [
_buildSectionHeader(context, localizations.discover),
_buildDiscoverWidget(context, localizations),
_buildSectionHeader(context, localizations.categories),
_buildCategoryWidget(context, localizations),
],
),
),
);
},
),
);
}
Widget _buildDiscoverWidget(
BuildContext context,
AppLocalizations localizations,
) {
return Column(
children: [
LongButton(
text: localizations.recommendations,
icon: Icons.star_rounded,
onPressed: () {
context.read<DiscoverBloc>().add(const NavigateToRecommendations());
// Navigator.of(context).push(
// AppTransitions.createSlideRoute(const BookRecommendationPage()),
// );
},
),
LongButton(
text: localizations.discover,
icon: Icons.search,
onPressed: () {
context.read<DiscoverBloc>().add(
NavigateToBookList(
title: localizations.discoverBooks,
discoverType: DiscoverType.discover,
),
);
Navigator.of(context).push(
AppTransitions.createSlideRoute(
DiscoverDetailsPage(
title: localizations.discoverBooks,
bookListType: DiscoverType.discover,
),
),
);
},
),
LongButton(
text: localizations.showHotBooks,
icon: Icons.local_fire_department_rounded,
onPressed: () {
context.read<DiscoverBloc>().add(
NavigateToBookList(
title: localizations.hotBooks,
discoverType: DiscoverType.hot,
),
);
Navigator.of(context).push(
AppTransitions.createSlideRoute(
DiscoverDetailsPage(
title: localizations.hotBooks,
bookListType: DiscoverType.hot,
),
),
);
},
),
LongButton(
text: localizations.showNewBooks,
icon: Icons.new_releases_rounded,
onPressed: () {
context.read<DiscoverBloc>().add(
NavigateToBookList(
title: localizations.newBooks,
discoverType: DiscoverType.newlyAdded,
),
);
Navigator.of(context).push(
AppTransitions.createSlideRoute(
DiscoverDetailsPage(
title: localizations.newBooks,
bookListType: DiscoverType.newlyAdded,
),
),
);
},
),
LongButton(
text: localizations.showRatedBooks,
icon: Icons.star_border_rounded,
onPressed: () {
context.read<DiscoverBloc>().add(
NavigateToBookList(
title: localizations.ratedBooks,
discoverType: DiscoverType.rated,
),
);
Navigator.of(context).push(
AppTransitions.createSlideRoute(
DiscoverDetailsPage(
title: localizations.ratedBooks,
bookListType: DiscoverType.rated,
),
),
);
},
),
],
);
}
Widget _buildCategoryWidget(
BuildContext context,
AppLocalizations localizations,
) {
return Column(
children: [
LongButton(
text: localizations.showAuthors,
icon: Icons.people_rounded,
onPressed: () {
context.read<DiscoverBloc>().add(
NavigateToBookList(
title: localizations.authors,
categoryType: CategoryType.author,
),
);
Navigator.of(context).push(
AppTransitions.createSlideRoute(
DiscoverDetailsPage(
title: localizations.authors,
categoryType: CategoryType.author,
),
),
);
},
),
LongButton(
text: localizations.showCategories,
icon: Icons.category_rounded,
onPressed: () {
context.read<DiscoverBloc>().add(
NavigateToBookList(
title: localizations.categories,
categoryType: CategoryType.category,
),
);
Navigator.of(context).push(
AppTransitions.createSlideRoute(
DiscoverDetailsPage(
title: localizations.categories,
categoryType: CategoryType.category,
),
),
);
},
),
LongButton(
text: localizations.showSeries,
icon: Icons.library_books_rounded,
onPressed: () {
context.read<DiscoverBloc>().add(
NavigateToBookList(
title: localizations.series,
categoryType: CategoryType.series,
),
);
Navigator.of(context).push(
AppTransitions.createSlideRoute(
DiscoverDetailsPage(
title: localizations.series,
categoryType: CategoryType.series,
),
),
);
},
),
LongButton(
text: localizations.showFormats,
icon: Icons.file_open_rounded,
onPressed: () {
context.read<DiscoverBloc>().add(
NavigateToBookList(
title: localizations.formats,
categoryType: CategoryType.formats,
),
);
Navigator.of(context).push(
AppTransitions.createSlideRoute(
DiscoverDetailsPage(
title: localizations.formats,
categoryType: CategoryType.formats,
),
),
);
},
),
LongButton(
text: localizations.showLanguages,
icon: Icons.language_rounded,
onPressed: () {
context.read<DiscoverBloc>().add(
NavigateToBookList(
title: localizations.languages,
categoryType: CategoryType.language,
),
);
Navigator.of(context).push(
AppTransitions.createSlideRoute(
DiscoverDetailsPage(
title: localizations.languages,
categoryType: CategoryType.language,
),
),
);
},
),
LongButton(
text: localizations.showPublishers,
icon: Icons.business_rounded,
onPressed: () {
context.read<DiscoverBloc>().add(
NavigateToBookList(
title: localizations.publishers,
categoryType: CategoryType.publisher,
),
);
Navigator.of(context).push(
AppTransitions.createSlideRoute(
DiscoverDetailsPage(
title: localizations.publishers,
categoryType: CategoryType.publisher,
),
),
);
},
),
LongButton(
text: localizations.showRatings,
icon: Icons.star_rounded,
onPressed: () {
context.read<DiscoverBloc>().add(
NavigateToBookList(
title: localizations.ratings,
categoryType: CategoryType.ratings,
),
);
Navigator.of(context).push(
AppTransitions.createSlideRoute(
DiscoverDetailsPage(
title: localizations.ratings,
categoryType: CategoryType.ratings,
),
),
);
},
),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
Divider(
color: Theme.of(context).colorScheme.primaryContainer,
thickness: 2,
),
],
),
);
}
}
@@ -0,0 +1,133 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_event.dart';
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_state.dart';
import 'package:calibre_web_companion/features/discover_details/data/repositories/discover_details_repository.dart';
class DiscoverDetailsBloc
extends Bloc<DiscoverDetailsEvent, DiscoverDetailsState> {
final DiscoverDetailsRepository repository;
DiscoverDetailsBloc({required this.repository})
: super(const DiscoverDetailsState()) {
on<LoadBooks>(_onLoadBooks);
on<LoadCategories>(_onLoadCategories);
on<LoadBooksFromPath>(_onLoadBooksFromPath);
on<RefreshData>(_onRefreshData);
}
Future<void> _onLoadBooks(
LoadBooks event,
Emitter<DiscoverDetailsState> emit,
) async {
emit(
state.copyWith(
status: DiscoverDetailsStatus.loading,
isShowingBooks: true,
isShowingCategories: false,
),
);
try {
final bookFeed = await repository.loadBooks(
event.type,
subPath: event.subPath,
);
emit(
state.copyWith(
status: DiscoverDetailsStatus.loaded,
bookFeed: bookFeed,
errorMessage: null,
),
);
} catch (e) {
emit(
state.copyWith(
status: DiscoverDetailsStatus.error,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onLoadCategories(
LoadCategories event,
Emitter<DiscoverDetailsState> emit,
) async {
emit(
state.copyWith(
status: DiscoverDetailsStatus.loading,
isShowingBooks: false,
isShowingCategories: true,
),
);
try {
final categoryFeed = await repository.loadCategories(
event.type,
subPath: event.subPath,
);
emit(
state.copyWith(
status: DiscoverDetailsStatus.loaded,
categoryFeed: categoryFeed,
errorMessage: null,
),
);
} catch (e) {
emit(
state.copyWith(
status: DiscoverDetailsStatus.error,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onLoadBooksFromPath(
LoadBooksFromPath event,
Emitter<DiscoverDetailsState> emit,
) async {
emit(
state.copyWith(
status: DiscoverDetailsStatus.loading,
isShowingBooks: true,
isShowingCategories: false,
),
);
try {
final bookFeed = await repository.loadBooksFromPath(event.fullPath);
emit(
state.copyWith(
status: DiscoverDetailsStatus.loaded,
bookFeed: bookFeed,
errorMessage: null,
),
);
} catch (e) {
emit(
state.copyWith(
status: DiscoverDetailsStatus.error,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onRefreshData(
RefreshData event,
Emitter<DiscoverDetailsState> emit,
) async {
// Reload the current data based on what was loaded last
if (state.isShowingBooks && state.bookFeed != null) {
// Re-trigger the last books load
// This would need to be enhanced to store the last parameters
} else if (state.isShowingCategories && state.categoryFeed != null) {
// Re-trigger the last categories load
// This would need to be enhanced to store the last parameters
}
}
}
@@ -0,0 +1,64 @@
import 'package:equatable/equatable.dart';
import 'package:calibre_web_companion/features/discover/blocs/discover_event.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/category_model.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/discover_details_model.dart';
abstract class DiscoverDetailsEvent extends Equatable {
const DiscoverDetailsEvent();
@override
List<Object?> get props => [];
}
class LoadBooks extends DiscoverDetailsEvent {
final DiscoverType type;
final String? subPath;
const LoadBooks(this.type, {this.subPath});
@override
List<Object?> get props => [type, subPath];
}
class LoadCategories extends DiscoverDetailsEvent {
final CategoryType type;
final String? subPath;
const LoadCategories(this.type, {this.subPath});
@override
List<Object?> get props => [type, subPath];
}
class LoadBooksFromPath extends DiscoverDetailsEvent {
final String fullPath;
const LoadBooksFromPath(this.fullPath);
@override
List<Object?> get props => [fullPath];
}
class RefreshData extends DiscoverDetailsEvent {
const RefreshData();
}
class NavigateToBook extends DiscoverDetailsEvent {
final DiscoverDetailsModel book;
const NavigateToBook(this.book);
@override
List<Object?> get props => [book];
}
class NavigateToCategory extends DiscoverDetailsEvent {
final CategoryModel category;
final CategoryType? currentCategoryType;
const NavigateToCategory(this.category, {this.currentCategoryType});
@override
List<Object?> get props => [category, currentCategoryType];
}
@@ -0,0 +1,52 @@
import 'package:equatable/equatable.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/category_feed_model.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/discover_feed_model.dart';
enum DiscoverDetailsStatus { initial, loading, loaded, error }
class DiscoverDetailsState extends Equatable {
final DiscoverDetailsStatus status;
final DiscoverFeedModel? bookFeed;
final CategoryFeed? categoryFeed;
final String? errorMessage;
final bool isShowingBooks;
final bool isShowingCategories;
const DiscoverDetailsState({
this.status = DiscoverDetailsStatus.initial,
this.bookFeed,
this.categoryFeed,
this.errorMessage,
this.isShowingBooks = false,
this.isShowingCategories = false,
});
DiscoverDetailsState copyWith({
DiscoverDetailsStatus? status,
DiscoverFeedModel? bookFeed,
CategoryFeed? categoryFeed,
String? errorMessage,
bool? isShowingBooks,
bool? isShowingCategories,
}) {
return DiscoverDetailsState(
status: status ?? this.status,
bookFeed: bookFeed ?? this.bookFeed,
categoryFeed: categoryFeed ?? this.categoryFeed,
errorMessage: errorMessage,
isShowingBooks: isShowingBooks ?? this.isShowingBooks,
isShowingCategories: isShowingCategories ?? this.isShowingCategories,
);
}
@override
List<Object?> get props => [
status,
bookFeed,
categoryFeed,
errorMessage,
isShowingBooks,
isShowingCategories,
];
}
@@ -0,0 +1,110 @@
import 'package:calibre_web_companion/core/services/api_service.dart';
import 'package:calibre_web_companion/features/discover/blocs/discover_event.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/category_feed_model.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/category_model.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/discover_details_model.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/discover_feed_model.dart';
import 'package:logger/logger.dart';
class DiscoverDetailsDatasource {
final ApiService apiService;
final Logger _logger = Logger();
DiscoverDetailsDatasource({required this.apiService});
Future<DiscoverFeedModel> loadBooks(
DiscoverType type, {
String? subPath,
}) async {
try {
final String path = _getBookListPath(type, subPath);
final jsonData = await apiService.getXmlAsJson(path, AuthMethod.basic);
final List<dynamic> items = jsonData['items']['entry'] ?? [];
final books =
items.map((item) => DiscoverDetailsModel.fromJson(item)).toList();
return DiscoverFeedModel(
books: books,
nextPageUrl: jsonData['nextPageUrl'],
);
} catch (e) {
throw Exception('Failed to load books: $e');
}
}
Future<CategoryFeed> loadCategories(
CategoryType type, {
String? subPath,
}) async {
try {
final String path = _getCategoryPath(type, subPath);
final jsonData = await apiService.getXmlAsJson(path, AuthMethod.basic);
_logger.d(jsonData);
final List<dynamic> items = jsonData['feed']["entry"] ?? [];
final categories =
items.map((item) => CategoryModel.fromJson(item)).toList();
return CategoryFeed(
categories: categories,
nextPageUrl: jsonData['nextPageUrl'],
);
} catch (e) {
throw Exception('Failed to load categories: $e');
}
}
Future<DiscoverFeedModel> loadBooksFromPath(String fullPath) async {
_logger.d('Loading books from path: $fullPath');
try {
final jsonData = await apiService.getXmlAsJson(
fullPath,
AuthMethod.basic,
);
_logger.d(jsonData);
final List<dynamic> items = jsonData['feed']['entry'] ?? [];
final books =
items.map((item) => DiscoverDetailsModel.fromJson(item)).toList();
return DiscoverFeedModel(
books: books,
nextPageUrl: jsonData['nextPageUrl'],
);
} catch (e) {
throw Exception('Failed to load books from path: $e');
}
}
String _getBookListPath(DiscoverType type, String? subPath) {
final Map<DiscoverType, String> paths = {
DiscoverType.discover: '/opds/discover',
DiscoverType.hot: '/opds/hot',
DiscoverType.newlyAdded: '/opds/new',
DiscoverType.rated: '/opds/rated',
DiscoverType.readbooks: '/opds/readbooks',
DiscoverType.unreadbooks: '/opds/unreadbooks',
};
String basePath = paths[type] ?? '/opds/discover';
return subPath != null ? '$basePath/$subPath' : basePath;
}
String _getCategoryPath(CategoryType type, String? subPath) {
final Map<CategoryType, String> paths = {
CategoryType.author: '/opds/author',
CategoryType.category: '/opds/category',
CategoryType.series: '/opds/series',
CategoryType.publisher: '/opds/publisher',
CategoryType.language: '/opds/language',
CategoryType.formats: '/opds/formats',
CategoryType.ratings: '/opds/ratings',
};
String basePath = paths[type] ?? '/opds/category';
return subPath != null ? '$basePath/$subPath' : basePath;
}
}
@@ -0,0 +1,13 @@
import 'package:equatable/equatable.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/category_model.dart';
class CategoryFeed extends Equatable {
final List<CategoryModel> categories;
final String? nextPageUrl;
const CategoryFeed({required this.categories, this.nextPageUrl});
@override
List<Object?> get props => [categories, nextPageUrl];
}
@@ -0,0 +1,15 @@
import 'package:equatable/equatable.dart';
class CategoryModel extends Equatable {
final String id;
final String title;
const CategoryModel({required this.id, required this.title});
factory CategoryModel.fromJson(Map<String, dynamic> json) {
return CategoryModel(id: json['id'] ?? '', title: json['title'] ?? '');
}
@override
List<Object?> get props => [id, title];
}
@@ -0,0 +1,27 @@
import 'package:equatable/equatable.dart';
class DiscoverDetailsModel extends Equatable {
final String id;
final String title;
final String author;
final String? coverUrl;
const DiscoverDetailsModel({
required this.id,
required this.title,
required this.author,
this.coverUrl,
});
factory DiscoverDetailsModel.fromJson(Map<String, dynamic> json) {
return DiscoverDetailsModel(
id: json['id'] ?? '',
title: json['title'] ?? '',
author: json['author']["name"] ?? '',
coverUrl: '',
);
}
@override
List<Object?> get props => [id, title, author, coverUrl];
}
@@ -0,0 +1,13 @@
import 'package:equatable/equatable.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/discover_details_model.dart';
class DiscoverFeedModel extends Equatable {
final List<DiscoverDetailsModel> books;
final String? nextPageUrl;
const DiscoverFeedModel({required this.books, this.nextPageUrl});
@override
List<Object?> get props => [books, nextPageUrl];
}
@@ -0,0 +1,46 @@
import 'package:calibre_web_companion/features/discover/blocs/discover_event.dart';
import 'package:calibre_web_companion/features/discover_details/data/datasources/discover_details_datasource.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/category_feed_model.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/discover_feed_model.dart';
class DiscoverDetailsRepository {
final DiscoverDetailsDatasource dataSource;
DiscoverDetailsRepository({required this.dataSource});
Future<DiscoverFeedModel> loadBooks(
DiscoverType type, {
String? subPath,
}) async {
try {
final books = await dataSource.loadBooks(type, subPath: subPath);
return books;
} catch (e) {
throw Exception('Failed to load books: $e');
}
}
Future<CategoryFeed> loadCategories(
CategoryType type, {
String? subPath,
}) async {
try {
final categories = await dataSource.loadCategories(
type,
subPath: subPath,
);
return categories;
} catch (e) {
throw Exception('Failed to load categories: $e');
}
}
Future<DiscoverFeedModel> loadBooksFromPath(String fullPath) async {
try {
final books = await dataSource.loadBooksFromPath(fullPath);
return books;
} catch (e) {
throw Exception('Failed to load books from path: $e');
}
}
}
@@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_bloc.dart';
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_event.dart';
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_state.dart';
import 'package:calibre_web_companion/core/services/app_transition.dart';
import 'package:calibre_web_companion/core/services/snackbar.dart';
import 'package:calibre_web_companion/features/book_view/presentation/widgets/book_skeleton.dart';
import 'package:calibre_web_companion/features/discover/blocs/discover_event.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/category_feed_model.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/category_model.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/discover_feed_model.dart';
import 'package:calibre_web_companion/features/discover_details/presentation/widgets/book_card_widget.dart';
import 'package:calibre_web_companion/features/discover_details/presentation/widgets/category_list_item_skeleton_widget.dart';
import 'package:calibre_web_companion/features/discover_details/presentation/widgets/category_list_item_widget.dart';
import 'package:calibre_web_companion/main.dart';
class DiscoverDetailsPage extends StatelessWidget {
final DiscoverType? bookListType;
final CategoryType? categoryType;
final String? subPath;
final String? fullPath;
final String title;
const DiscoverDetailsPage({
super.key,
this.bookListType,
this.categoryType,
this.subPath,
this.fullPath,
required this.title,
}) : assert(
bookListType != null || categoryType != null || fullPath != null,
'Either bookListType, categoryType, or fullPath must be provided',
);
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
return BlocProvider(
create: (context) {
final bloc = getIt<DiscoverDetailsBloc>();
// Load initial data based on provided parameters
if (fullPath != null) {
bloc.add(LoadBooksFromPath(fullPath!));
} else if (bookListType != null) {
bloc.add(LoadBooks(bookListType!, subPath: subPath));
} else if (categoryType != null) {
bloc.add(LoadCategories(categoryType!, subPath: subPath));
}
return bloc;
},
child: BlocConsumer<DiscoverDetailsBloc, DiscoverDetailsState>(
listener: (context, state) {
if (state.status == DiscoverDetailsStatus.error) {
context.showSnackBar(
"${localizations.errorLoadingData}: ${state.errorMessage}",
isError: true,
);
}
},
builder: (context, state) {
return Scaffold(
appBar: AppBar(
title: _buildAppBarTitle(context, title, categoryType),
),
body: RefreshIndicator(
onRefresh: () async {
context.read<DiscoverDetailsBloc>().add(const RefreshData());
},
child: _buildBody(context, state, localizations),
),
);
},
),
);
}
Widget _buildAppBarTitle(
BuildContext context,
String title,
CategoryType? categoryType,
) {
double ratingValue = _isRatingValue(title);
if (ratingValue == -1) {
return Text(title);
} else {
return _buildStarRating(context, ratingValue);
}
}
double _isRatingValue(String title) {
final parts = title.split(' ');
for (final part in parts) {
if (double.tryParse(part) != null) {
return double.parse(part);
}
}
return -1;
}
Widget _buildStarRating(BuildContext context, double ratingValue) {
final int fullStars = ratingValue.floor();
final double remainder = ratingValue - fullStars;
final List<Widget> stars = [];
for (int i = 0; i < fullStars; i++) {
stars.add(const Icon(Icons.star, color: Colors.amber, size: 24));
}
if (remainder >= 0.25 && remainder < 0.75) {
stars.add(const Icon(Icons.star_half, color: Colors.amber, size: 24));
} else if (remainder >= 0.75) {
stars.add(const Icon(Icons.star, color: Colors.amber, size: 24));
}
while (stars.length < 5) {
stars.add(const Icon(Icons.star_border, color: Colors.amber, size: 24));
}
final formattedRating = ratingValue.toStringAsFixed(1);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
...stars,
const SizedBox(width: 8),
Text('($formattedRating)'),
],
);
}
Widget _buildBody(
BuildContext context,
DiscoverDetailsState state,
AppLocalizations localizations,
) {
if (state.status == DiscoverDetailsStatus.loading) {
return state.isShowingCategories
? _buildCategoryListSkeletons()
: _buildBookGridSkeletons();
}
if (state.status == DiscoverDetailsStatus.error) {
return _buildErrorWidget(context, state, localizations);
}
if (state.isShowingBooks &&
state.bookFeed != null &&
state.bookFeed!.books.isNotEmpty) {
return _buildBookGrid(context, state.bookFeed!);
}
if (state.isShowingCategories &&
state.categoryFeed != null &&
state.categoryFeed!.categories.isNotEmpty) {
return _buildCategoryList(context, state.categoryFeed!);
}
return _buildEmptyState(context, localizations);
}
Widget _buildBookGrid(BuildContext context, DiscoverFeedModel feed) {
return GridView.builder(
padding: const EdgeInsets.all(16.0),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.7,
crossAxisSpacing: 16.0,
mainAxisSpacing: 16.0,
),
itemCount: feed.books.length,
itemBuilder: (context, index) {
final book = feed.books[index];
return BookCard(
book: book,
onTap: () {},
// TODO: Implement navigation to book details page
// () => Navigator.of(context).push(
// AppTransitions.createSlideRoute(
// BookDetailsPage(bookListModel: book, bookUuid: book.id),
// ),
// ),
);
},
);
}
Widget _buildCategoryList(BuildContext context, CategoryFeed feed) {
return ListView.builder(
itemCount: feed.categories.length,
itemBuilder: (context, index) {
final category = feed.categories[index];
return CategoryListItem(
category: category,
type: categoryType ?? CategoryType.category,
onTap: () => _navigateToCategory(context, category),
);
},
);
}
Widget _buildBookGridSkeletons() {
return GridView.builder(
padding: const EdgeInsets.all(16.0),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.7,
crossAxisSpacing: 16.0,
mainAxisSpacing: 16.0,
),
itemCount: 10,
itemBuilder: (context, index) => const BookCardSkeleton(),
);
}
Widget _buildCategoryListSkeletons() {
return ListView.builder(
itemCount: 15,
itemBuilder: (context, index) => const CategoryListItemSkeleton(),
);
}
Widget _buildErrorWidget(
BuildContext context,
DiscoverDetailsState state,
AppLocalizations localizations,
) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
localizations.errorLoadingData,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(state.errorMessage ?? localizations.unknownError),
const SizedBox(height: 16),
ElevatedButton(
onPressed:
() => context.read<DiscoverDetailsBloc>().add(
const RefreshData(),
),
child: Text(localizations.tryAgain),
),
],
),
);
}
Widget _buildEmptyState(
BuildContext context,
AppLocalizations localizations,
) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Theme.of(context).colorScheme.primary.withValues(alpha: .5),
),
const SizedBox(height: 16),
Text(
localizations.noDataFound,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
);
}
void _navigateToCategory(BuildContext context, CategoryModel category) {
// Navigation logic for categories would go here
// This would need to analyze the category's navigationUrl and navigate accordingly
Navigator.of(context).push(
AppTransitions.createSlideRoute(
DiscoverDetailsPage(title: category.title, fullPath: category.id),
),
);
}
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/discover_details_model.dart';
import 'package:calibre_web_companion/features/discover_details/presentation/widgets/book_card_widget.dart';
class BookCardSkeleton extends StatelessWidget {
const BookCardSkeleton({super.key});
@override
Widget build(BuildContext context) {
return Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHighest,
highlightColor: Theme.of(context).colorScheme.surface,
),
child: BookCard(
book: const DiscoverDetailsModel(
id: 'skeleton',
title: 'Loading Book Title',
author: 'Loading Author Name',
),
),
);
}
}
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/discover_details_model.dart';
class BookCard extends StatelessWidget {
final DiscoverDetailsModel book;
final VoidCallback? onTap;
const BookCard({super.key, required this.book, this.onTap});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
child:
book.coverUrl != null
? ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: Image.network(
book.coverUrl!,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
_buildPlaceholder(context),
),
)
: _buildPlaceholder(context),
),
),
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book.title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
book.author,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: .7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
],
),
),
);
}
Widget _buildPlaceholder(BuildContext context) {
return Center(
child: Icon(
Icons.book,
size: 48,
color: Theme.of(context).colorScheme.primary.withValues(alpha: .5),
),
);
}
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:calibre_web_companion/features/discover/blocs/discover_event.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/category_model.dart';
import 'package:calibre_web_companion/features/discover_details/presentation/widgets/category_list_item_widget.dart';
class CategoryListItemSkeleton extends StatelessWidget {
const CategoryListItemSkeleton({super.key});
@override
Widget build(BuildContext context) {
return Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHighest,
highlightColor: Theme.of(context).colorScheme.surface,
),
child: CategoryListItem(
category: const CategoryModel(
id: 'skeleton-id',
title: 'Loading Category Name',
),
type: CategoryType.category,
onTap: () {},
),
);
}
}
@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:calibre_web_companion/features/discover/blocs/discover_event.dart';
import 'package:calibre_web_companion/features/discover_details/data/models/category_model.dart';
class CategoryListItem extends StatelessWidget {
final CategoryModel category;
final CategoryType type;
final VoidCallback onTap;
const CategoryListItem({
super.key,
required this.category,
required this.type,
required this.onTap,
});
@override
Widget build(BuildContext context) {
BorderRadius borderRadius = BorderRadius.circular(8.0);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 3,
shape: RoundedRectangleBorder(borderRadius: borderRadius),
child: Material(
color: Theme.of(context).cardColor,
borderRadius: borderRadius,
child: InkWell(
borderRadius: borderRadius,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child:
type == CategoryType.ratings
? _buildRatingStars(context, category.title)
: Text(
category.title,
style: Theme.of(context).textTheme.titleMedium,
),
),
Icon(
Icons.arrow_forward_ios_rounded,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
),
),
);
}
double _extractRating(String title) {
final RegExp regex = RegExp(r'(\d+[.,]?\d*)');
final match = regex.firstMatch(title);
if (match != null) {
final String number = match.group(1)!.replaceAll(',', '.');
try {
return double.parse(number);
} catch (e) {
return 0;
}
}
return 0;
}
Widget _buildRatingStars(BuildContext context, String title) {
final double rating = _extractRating(title);
final int fullStars = rating.floor();
final bool hasHalfStar = (rating - fullStars) >= 0.5;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
for (int i = 0; i < 5; i++)
Icon(
i < fullStars
? Icons.star
: (i == fullStars && hasHalfStar)
? Icons.star_half
: Icons.star_border,
size: 20,
color: Colors.amber,
),
const SizedBox(width: 6),
Text(
rating.toStringAsFixed(1),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
),
],
);
}
}
+87
View File
@@ -0,0 +1,87 @@
import 'package:calibre_web_companion/core/exceptions/auth_exception.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:logger/logger.dart';
import 'package:calibre_web_companion/features/login/bloc/login_event.dart';
import 'package:calibre_web_companion/features/login/bloc/login_state.dart';
import 'package:calibre_web_companion/features/login/data/repositories/login_repository.dart';
class LoginBloc extends Bloc<LoginEvent, LoginState> {
final LoginRepository _loginRepository;
final Logger _logger = Logger();
LoginBloc({LoginRepository? loginRepository})
: _loginRepository = loginRepository ?? LoginRepository(),
super(const LoginState()) {
on<EnterUrl>(_onEnterUrl);
on<EnterUsername>(_onEnterUsername);
on<EnterPassword>(_onEnterPassword);
on<SubmitLogin>(_onSubmitLogin);
}
void _onEnterUrl(EnterUrl event, Emitter<LoginState> emit) {
emit(state.copyWith(url: event.url));
}
void _onEnterUsername(EnterUsername event, Emitter<LoginState> emit) {
emit(state.copyWith(username: event.username));
}
void _onEnterPassword(EnterPassword event, Emitter<LoginState> emit) {
emit(state.copyWith(password: event.password));
}
Future<void> _onSubmitLogin(
SubmitLogin event,
Emitter<LoginState> emit,
) async {
emit(
state.copyWith(
isLoading: true,
isFailure: false,
isSuccess: false,
errorMessage: null,
),
);
try {
final success = await _loginRepository.login(
state.username,
state.password,
state.url,
);
if (success) {
_logger.i('Login successful');
emit(
state.copyWith(isLoading: false, isSuccess: true, isFailure: false),
);
} else {
_logger.w('Login failed');
emit(
state.copyWith(
isLoading: false,
isSuccess: false,
isFailure: true,
errorMessage: 'Invalid username or password',
),
);
}
} catch (e) {
String errorMessage =
e is AuthException
? e.toString()
: e.toString().replaceAll(RegExp(r'^Exception: '), '');
emit(
state.copyWith(
isLoading: false,
isSuccess: false,
isFailure: true,
errorMessage: errorMessage,
),
);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import 'package:equatable/equatable.dart';
abstract class LoginEvent extends Equatable {
const LoginEvent();
@override
List<Object?> get props => [];
}
class EnterUrl extends LoginEvent {
final String url;
const EnterUrl(this.url);
@override
List<Object?> get props => [url];
}
class EnterUsername extends LoginEvent {
final String username;
const EnterUsername(this.username);
@override
List<Object?> get props => [username];
}
class EnterPassword extends LoginEvent {
final String password;
const EnterPassword(this.password);
@override
List<Object?> get props => [password];
}
class SubmitLogin extends LoginEvent {
const SubmitLogin();
}
+52
View File
@@ -0,0 +1,52 @@
import 'package:equatable/equatable.dart';
class LoginState extends Equatable {
final String url;
final String username;
final String password;
final bool isLoading;
final bool isSuccess;
final bool isFailure;
final String? errorMessage;
const LoginState({
this.url = '',
this.username = '',
this.password = '',
this.isLoading = false,
this.isSuccess = false,
this.isFailure = false,
this.errorMessage,
});
LoginState copyWith({
String? url,
String? username,
String? password,
bool? isLoading,
bool? isSuccess,
bool? isFailure,
String? errorMessage,
}) {
return LoginState(
url: url ?? this.url,
username: username ?? this.username,
password: password ?? this.password,
isLoading: isLoading ?? this.isLoading,
isSuccess: isSuccess ?? this.isSuccess,
isFailure: isFailure ?? this.isFailure,
errorMessage: errorMessage,
);
}
@override
List<Object?> get props => [
url,
username,
password,
isLoading,
isSuccess,
isFailure,
errorMessage,
];
}
@@ -0,0 +1,109 @@
import 'package:calibre_web_companion/core/exceptions/auth_exception.dart';
import 'package:logger/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../../core/services/api_service.dart';
import '../models/login_credentials.dart';
class LoginDataSource {
final ApiService _apiService;
final Logger _logger = Logger();
LoginDataSource({ApiService? apiService})
: _apiService = apiService ?? ApiService();
/// Attempts to login with the given credentials
Future<bool> login(LoginCredentials credentials) async {
try {
// Save base URL to shared preferences
final prefs = await SharedPreferences.getInstance();
await prefs.setString('base_url', credentials.baseUrl);
await prefs.setString('username', credentials.username);
await prefs.setString('password', credentials.password);
// Initialize API with new values
await _apiService.initialize();
// Perform login request
final response = await _apiService.post(
'/login',
null,
credentials.toFormData(),
AuthMethod.none,
contentType: 'application/x-www-form-urlencoded',
useCsrf: true,
);
if (response.statusCode == 200 || response.statusCode == 302) {
final isSuccess = !response.body.contains('flash_danger');
if (isSuccess) {
if (response.headers.containsKey('set-cookie')) {
final cookie = response.headers['set-cookie']!;
await prefs.setString('calibre_web_session', cookie);
await _apiService.initialize();
_logger.i('Session cookie saved');
} else {
_logger.w('No cookie received in login response');
}
_logger.i('Login successful');
return true;
} else {
_logger.w('Login failed - invalid credentials');
throw AuthException('Invalid username or password');
}
}
_logger.e(
'Login failed: ${response.reasonPhrase ?? response.body} ${response.statusCode}',
);
throw AuthException(
response.reasonPhrase ?? response.body,
statusCode: response.statusCode,
);
} catch (e) {
_logger.e("Error during login: $e");
if (e is AuthException) {
rethrow;
}
throw AuthException('Connection error: ${e.toString().split(': ').last}');
}
}
/// Checks if there are stored credentials and if they are valid by making a test request
Future<bool> hasStoredCredentials() async {
_logger.i('Checking stored credentials...');
final prefs = await SharedPreferences.getInstance();
final baseUrl = prefs.getString('base_url');
final username = prefs.getString('username');
final password = prefs.getString('password');
if (baseUrl == null || username == null || password == null) {
return false;
}
try {
// Re-initialize API with stored credentials
await _apiService.initialize();
// Attempt a simple authenticated request (e.g., to /opds or /admin)
final response = await _apiService.get('/opds', AuthMethod.basic);
_logger.i(response.body);
// Consider 200 as valid credentials
return response.statusCode == 200;
} catch (e) {
_logger.w('Credential validation failed: $e');
return false;
}
}
/// Clears stored credentials
Future<void> clearCredentials() async {
// final prefs = await SharedPreferences.getInstance();
// await prefs.remove('base_url');
// await prefs.remove('username');
// await prefs.remove('password');
// await prefs.remove('calibre_web_session');
}
}
@@ -0,0 +1,15 @@
class LoginCredentials {
final String username;
final String password;
final String baseUrl;
LoginCredentials({
required this.username,
required this.password,
required this.baseUrl,
});
Map<String, String> toFormData() {
return {'username': username, 'password': password};
}
}
@@ -0,0 +1,39 @@
import 'package:calibre_web_companion/core/exceptions/auth_exception.dart';
import 'package:logger/logger.dart';
import 'package:calibre_web_companion/features/login/data/datasources/login_datasource.dart';
import 'package:calibre_web_companion/features/login/data/models/login_credentials.dart';
class LoginRepository {
final LoginDataSource _dataSource;
final Logger _logger = Logger();
LoginRepository({LoginDataSource? dataSource})
: _dataSource = dataSource ?? LoginDataSource();
Future<bool> login(String username, String password, String baseUrl) async {
try {
final credentials = LoginCredentials(
username: username,
password: password,
baseUrl: baseUrl,
);
return await _dataSource.login(credentials);
} catch (e) {
_logger.e('Login error: $e');
if (e is AuthException) {
rethrow;
}
throw Exception('Login failed: $e');
}
}
Future<bool> isLoggedIn() async {
return _dataSource.hasStoredCredentials();
}
Future<void> logout() async {
return _dataSource.clearCredentials();
}
}
@@ -0,0 +1,40 @@
import 'package:calibre_web_companion/features/book_view/presentation/pages/book_view_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:calibre_web_companion/core/services/snackbar.dart';
import 'package:calibre_web_companion/features/login/bloc/login_bloc.dart';
import 'package:calibre_web_companion/features/login/bloc/login_state.dart';
import 'package:calibre_web_companion/features/login/presentation/widgets/login_form_widget.dart';
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(localizations.loginToCalibreWb)),
body: BlocProvider(
create: (context) => LoginBloc(),
child: BlocListener<LoginBloc, LoginState>(
listener: (context, state) {
if (state.isSuccess) {
// TODO: Change to Homepage
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const BookViewPage()),
);
}
if (state.isFailure && state.errorMessage != null) {
context.showSnackBar(state.errorMessage!, isError: true);
}
},
child: const Center(child: SingleChildScrollView(child: LoginForm())),
),
),
);
}
}
@@ -0,0 +1,284 @@
import 'package:calibre_web_companion/core/services/app_transition.dart';
import 'package:calibre_web_companion/features/login/presentation/widgets/login_text_field.dart';
import 'package:calibre_web_companion/features/login_settings/presentation/pages/login_settings_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:calibre_web_companion/features/login/bloc/login_bloc.dart';
import 'package:calibre_web_companion/features/login/bloc/login_event.dart';
import 'package:calibre_web_companion/features/login/bloc/login_state.dart';
import 'package:calibre_web_companion/core/services/snackbar.dart';
class LoginForm extends StatefulWidget {
const LoginForm({super.key});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _urlController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_urlController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
return BlocBuilder<LoginBloc, LoginState>(
builder: (context, state) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: AutofillGroup(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// App logo or icon
Center(
child: Icon(
Icons.menu_book_rounded,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 24),
// Server URL field
LoginTextField(
controller: _urlController,
labelText: localizations.calibreWebUrl,
hintText: localizations.enterCalibreWebUrl,
prefixIcon: Icons.link_rounded,
autofillHint: AutofillHints.url,
keyboardType: TextInputType.url,
onChanged:
(value) =>
context.read<LoginBloc>().add(EnterUrl(value)),
),
const SizedBox(height: 16),
// Username field
LoginTextField(
controller: _usernameController,
labelText: localizations.username,
hintText: localizations.enterYourUsername,
prefixIcon: Icons.person_rounded,
autofillHint: AutofillHints.username,
onChanged:
(value) => context.read<LoginBloc>().add(
EnterUsername(value),
),
),
const SizedBox(height: 16),
// Password field
LoginTextField(
controller: _passwordController,
labelText: localizations.password,
hintText: localizations.enterYourPassword,
obscureText: true,
prefixIcon: Icons.lock_rounded,
autofillHint: AutofillHints.password,
textInputAction: TextInputAction.done,
onChanged:
(value) => context.read<LoginBloc>().add(
EnterPassword(value),
),
onSubmitted:
(_) => _handleLogin(context, localizations),
),
// Error message if any
if (state.errorMessage != null) ...[
const SizedBox(height: 16),
Text(
state.errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
state.isLoading
? Center(
child: Container(
width: double.infinity,
height: 50,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12.0),
),
child: const Center(
child: CircularProgressIndicator(),
),
),
)
: Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
child: Row(
children: [
Expanded(
flex: 5,
child: Material(
color:
Theme.of(
context,
).colorScheme.primaryContainer,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12.0),
bottomLeft: Radius.circular(12.0),
),
child: InkWell(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12.0),
bottomLeft: Radius.circular(12.0),
),
onTap:
() => _handleLogin(
context,
localizations,
),
child: Container(
height: 50,
alignment: Alignment.center,
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
Icons.login_rounded,
color:
Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
const SizedBox(width: 12),
Text(
localizations.login,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color:
Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
],
),
),
),
),
),
Container(
height: 50,
width: 1,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: .3),
),
Expanded(
flex: 1,
child: Material(
color:
Theme.of(
context,
).colorScheme.primaryContainer,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(12.0),
bottomRight: Radius.circular(12.0),
),
child: InkWell(
borderRadius: const BorderRadius.only(
topRight: Radius.circular(12.0),
bottomRight: Radius.circular(12.0),
),
onTap: () {
Navigator.of(context).push(
AppTransitions.createSlideRoute(
const LoginSettingsPage(),
),
);
},
child: Container(
height: 50,
alignment: Alignment.center,
child: Icon(
Icons.settings,
color:
Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
),
),
),
),
],
),
),
],
),
),
),
),
),
);
},
);
}
void _handleLogin(BuildContext context, AppLocalizations localizations) {
// Don't try to log in if already loading
if (context.read<LoginBloc>().state.isLoading) return;
// Validate inputs
if (_urlController.text.isEmpty ||
_usernameController.text.isEmpty ||
_passwordController.text.isEmpty) {
context.showSnackBar(localizations.pleaseFillInAllFields, isError: true);
return;
}
// Fix URL if needed (add https:// if missing)
String url = _urlController.text.trim();
if (!url.startsWith('http://') && !url.startsWith('https://')) {
context.showSnackBar(
localizations.urlMustStartWithHttpOrHttps,
isError: true,
);
return;
}
// Dispatch login event
context.read<LoginBloc>().add(const SubmitLogin());
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
class LoginTextField extends StatelessWidget {
final TextEditingController controller;
final String labelText;
final String? hintText;
final IconData? prefixIcon;
final bool obscureText;
final String? autofillHint;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
const LoginTextField({
super.key,
required this.controller,
required this.labelText,
this.hintText,
this.prefixIcon,
this.obscureText = false,
this.autofillHint,
this.keyboardType,
this.textInputAction,
this.onChanged,
this.onSubmitted,
});
@override
Widget build(BuildContext context) {
final ValueNotifier<bool> isObscureTextNotifier = ValueNotifier<bool>(
obscureText,
);
return ValueListenableBuilder<bool>(
valueListenable: isObscureTextNotifier,
builder: (context, isObscureText, _) {
return TextField(
controller: controller,
autofillHints: autofillHint != null ? [autofillHint!] : null,
keyboardType: keyboardType,
textInputAction: textInputAction,
onSubmitted: onSubmitted,
onChanged: onChanged,
obscureText: isObscureText,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.0),
),
labelText: labelText,
hintText: hintText,
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
suffixIcon:
obscureText
? IconButton(
icon: Icon(
isObscureText ? Icons.visibility : Icons.visibility_off,
),
onPressed: () {
isObscureTextNotifier.value = !isObscureText;
},
)
: null,
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 14.0,
),
),
);
},
);
}
}
@@ -0,0 +1,119 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:logger/logger.dart';
import 'package:calibre_web_companion/features/login_settings/bloc/login_settings_event.dart';
import 'package:calibre_web_companion/features/login_settings/bloc/login_settings_state.dart';
import 'package:calibre_web_companion/features/login_settings/data/models/custom_header.dart';
import 'package:calibre_web_companion/features/login_settings/data/repositories/login_settings_repository.dart';
class LoginSettingsBloc extends Bloc<LoginSettingsEvent, LoginSettingsState> {
final Logger _logger = Logger();
final LoginSettingsRepository _loginSettingsRepository;
LoginSettingsBloc({LoginSettingsRepository? loginSettingsRepository})
: _loginSettingsRepository =
loginSettingsRepository ?? LoginSettingsRepository(),
super(const LoginSettingsState()) {
on<LoadLoginSettings>(_onLoadSettings);
on<AddCustomHeader>(_onAddCustomHeader);
on<DeleteCustomHeader>(_onDeleteCustomHeader);
on<UpdateCustomHeaderKey>(_onUpdateCustomHeaderKey);
on<UpdateCustomHeaderValue>(_onUpdateCustomHeaderValue);
on<SaveLoginSettings>(_onSaveSettings);
}
Future<void> _onLoadSettings(
LoadLoginSettings event,
Emitter<LoginSettingsState> emit,
) async {
emit(state.copyWith(isLoading: true, isSaved: false));
try {
final headers = await _loginSettingsRepository.getCustomHeaders();
emit(state.copyWith(customHeaders: headers, isLoading: false));
} catch (e) {
_logger.e('Error loading login settings: $e');
emit(
state.copyWith(
isLoading: false,
errorMessage: 'Failed to load login settings: $e',
),
);
}
}
void _onAddCustomHeader(
AddCustomHeader event,
Emitter<LoginSettingsState> emit,
) {
final updatedHeaders = List<CustomHeaderModel>.from(state.customHeaders)
..add(CustomHeaderModel(key: '', value: ''));
emit(state.copyWith(customHeaders: updatedHeaders, isSaved: false));
}
void _onDeleteCustomHeader(
DeleteCustomHeader event,
Emitter<LoginSettingsState> emit,
) {
final updatedHeaders = List<CustomHeaderModel>.from(state.customHeaders);
if (event.index >= 0 && event.index < updatedHeaders.length) {
updatedHeaders.removeAt(event.index);
emit(state.copyWith(customHeaders: updatedHeaders, isSaved: false));
}
}
void _onUpdateCustomHeaderKey(
UpdateCustomHeaderKey event,
Emitter<LoginSettingsState> emit,
) {
final updatedHeaders = List<CustomHeaderModel>.from(state.customHeaders);
if (event.index >= 0 && event.index < updatedHeaders.length) {
final oldHeader = updatedHeaders[event.index];
updatedHeaders[event.index] = CustomHeaderModel(
key: event.newKey,
value: oldHeader.value,
);
emit(state.copyWith(customHeaders: updatedHeaders, isSaved: false));
}
}
void _onUpdateCustomHeaderValue(
UpdateCustomHeaderValue event,
Emitter<LoginSettingsState> emit,
) {
final updatedHeaders = List<CustomHeaderModel>.from(state.customHeaders);
if (event.index >= 0 && event.index < updatedHeaders.length) {
final oldHeader = updatedHeaders[event.index];
updatedHeaders[event.index] = CustomHeaderModel(
key: oldHeader.key,
value: event.newValue,
);
emit(state.copyWith(customHeaders: updatedHeaders, isSaved: false));
}
}
Future<void> _onSaveSettings(
SaveLoginSettings event,
Emitter<LoginSettingsState> emit,
) async {
emit(state.copyWith(isLoading: true, isSaved: false));
try {
await _loginSettingsRepository.saveCustomHeaders(state.customHeaders);
emit(state.copyWith(isLoading: false, isSaved: true));
} catch (e) {
_logger.e('Error saving settings: $e');
emit(
state.copyWith(
isLoading: false,
errorMessage: 'Failed to save settings: $e',
isSaved: false,
),
);
}
}
}
@@ -0,0 +1,49 @@
import 'package:equatable/equatable.dart';
abstract class LoginSettingsEvent extends Equatable {
const LoginSettingsEvent();
@override
List<Object?> get props => [];
}
class LoadLoginSettings extends LoginSettingsEvent {
const LoadLoginSettings();
}
class AddCustomHeader extends LoginSettingsEvent {
const AddCustomHeader();
}
class DeleteCustomHeader extends LoginSettingsEvent {
final int index;
const DeleteCustomHeader(this.index);
@override
List<Object?> get props => [index];
}
class UpdateCustomHeaderKey extends LoginSettingsEvent {
final int index;
final String newKey;
const UpdateCustomHeaderKey(this.index, this.newKey);
@override
List<Object?> get props => [index, newKey];
}
class UpdateCustomHeaderValue extends LoginSettingsEvent {
final int index;
final String newValue;
const UpdateCustomHeaderValue(this.index, this.newValue);
@override
List<Object?> get props => [index, newValue];
}
class SaveLoginSettings extends LoginSettingsEvent {
const SaveLoginSettings();
}
@@ -0,0 +1,34 @@
import 'package:equatable/equatable.dart';
import 'package:calibre_web_companion/features/login_settings/data/models/custom_header.dart';
class LoginSettingsState extends Equatable {
final List<CustomHeaderModel> customHeaders;
final bool isLoading;
final bool isSaved;
final String? errorMessage;
const LoginSettingsState({
this.customHeaders = const [],
this.isLoading = false,
this.isSaved = false,
this.errorMessage,
});
LoginSettingsState copyWith({
List<CustomHeaderModel>? customHeaders,
bool? isLoading,
bool? isSaved,
String? errorMessage,
}) {
return LoginSettingsState(
customHeaders: customHeaders ?? this.customHeaders,
isLoading: isLoading ?? this.isLoading,
isSaved: isSaved ?? this.isSaved,
errorMessage: errorMessage,
);
}
@override
List<Object?> get props => [customHeaders, isLoading, isSaved, errorMessage];
}
@@ -0,0 +1,44 @@
import 'dart:convert';
import 'package:logger/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:calibre_web_companion/features/login_settings/data/models/custom_header.dart';
class LoginSettingsDatasource {
final SharedPreferences _preferences;
final Logger _logger = Logger();
LoginSettingsDatasource({required SharedPreferences preferences})
: _preferences = preferences;
static const String _customHeadersKey = 'custom_login_headers';
Future<List<CustomHeaderModel>> getCustomHeaders() async {
try {
final String jsonString =
_preferences.getString(_customHeadersKey) ?? '[]';
final List<dynamic> jsonList = json.decode(jsonString);
_logger.i('Loaded headers: $jsonList');
return CustomHeaderModel.fromJsonList(jsonList);
} catch (e) {
_logger.e('Error loading headers: $e');
return [];
}
}
Future<void> saveCustomHeaders(List<CustomHeaderModel> headers) async {
try {
final List<Map<String, dynamic>> jsonList =
headers.map((header) => {header.key: header.value}).toList();
final String jsonString = json.encode(jsonList);
await _preferences.setString(_customHeadersKey, jsonString);
_logger.i('Saved headers: $jsonList');
} catch (e) {
_logger.e('Error saving headers: $e');
throw Exception('Failed to save headers: $e');
}
}
}
@@ -0,0 +1,35 @@
class CustomHeaderModel {
final String key;
final String value;
CustomHeaderModel({required this.key, required this.value});
factory CustomHeaderModel.fromMap(Map<String, dynamic> map) {
return CustomHeaderModel(
key: map['key'] as String,
value: map['value'] as String,
);
}
Map<String, String> toMap() => {'key': key, 'value': value};
@override
String toString() => 'CustomHeader(key: $key, value: $value)';
static List<CustomHeaderModel> fromJsonList(List<dynamic> jsonList) {
return jsonList
.map(
(item) => CustomHeaderModel.fromMap(Map<String, dynamic>.from(item)),
)
.toList();
}
static List<Map<String, dynamic>> toJsonList(
List<CustomHeaderModel?> headers,
) {
return headers
.where((header) => header != null)
.map((header) => header!.toMap())
.toList();
}
}
@@ -0,0 +1,44 @@
import 'package:logger/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:calibre_web_companion/features/login_settings/data/datasources/login_settings_datasource.dart';
import 'package:calibre_web_companion/features/login_settings/data/models/custom_header.dart';
class LoginSettingsRepository {
final LoginSettingsDatasource _loginSettingsDatasource;
final Logger _logger = Logger();
LoginSettingsRepository({LoginSettingsDatasource? loginSettingsDatasource})
: _loginSettingsDatasource =
loginSettingsDatasource ??
LoginSettingsDatasource(
preferences: SharedPreferences.getInstance() as SharedPreferences,
);
Future<List<CustomHeaderModel>> getCustomHeaders() async {
try {
final headers = await _loginSettingsDatasource.getCustomHeaders();
return headers;
} catch (e) {
_logger.e('Error getting custom headers: $e');
return [];
}
}
Future<void> saveCustomHeaders(List<CustomHeaderModel> headers) async {
try {
final headerModels =
headers
.map(
(header) =>
CustomHeaderModel(key: header.key, value: header.value),
)
.toList();
await _loginSettingsDatasource.saveCustomHeaders(headerModels);
} catch (e) {
_logger.e('Error saving custom headers: $e');
throw Exception('Failed to save custom headers: $e');
}
}
}
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:calibre_web_companion/features/login_settings/bloc/login_settings_bloc.dart';
import 'package:calibre_web_companion/features/login_settings/bloc/login_settings_event.dart';
import 'package:calibre_web_companion/features/login_settings/bloc/login_settings_state.dart';
import 'package:calibre_web_companion/core/services/snackbar.dart';
import 'package:calibre_web_companion/features/login_settings/presentation/widgets/header_section_widget.dart';
class LoginSettingsPage extends StatefulWidget {
const LoginSettingsPage({super.key});
@override
State<LoginSettingsPage> createState() => _LoginSettingsPage();
}
class _LoginSettingsPage extends State<LoginSettingsPage> {
@override
void initState() {
super.initState();
context.read<LoginSettingsBloc>().add(const SaveLoginSettings());
}
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
return BlocConsumer<LoginSettingsBloc, LoginSettingsState>(
listener: (context, state) {
if (state.isSaved) {
context.showSnackBar(localizations.settingsSaved);
}
if (state.errorMessage != null) {
context.showSnackBar(state.errorMessage!, isError: true);
}
},
builder: (context, state) {
return Scaffold(
appBar: AppBar(
title: Text(localizations.connectionSettings),
actions: [
IconButton(
icon: const Icon(Icons.save),
tooltip: localizations.save,
onPressed: () {
context.read<LoginSettingsBloc>().add(
const SaveLoginSettings(),
);
},
),
],
),
body:
state.isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle(
context,
localizations.costumHttpPHeader,
),
const HeadersSection(),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 16.0,
),
child: Center(
child: ElevatedButton.icon(
onPressed: () {
context.read<LoginSettingsBloc>().add(
const AddCustomHeader(),
);
},
icon: const Icon(Icons.add),
label: Text(localizations.addHeader),
style: ElevatedButton.styleFrom(
backgroundColor:
Theme.of(
context,
).colorScheme.primaryContainer,
foregroundColor:
Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
),
),
),
],
),
),
);
},
);
}
Widget _buildSectionTitle(BuildContext context, String title) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.secondary,
),
),
);
}
}
@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:calibre_web_companion/features/login_settings/bloc/login_settings_bloc.dart';
import 'package:calibre_web_companion/features/login_settings/bloc/login_settings_event.dart';
import 'package:calibre_web_companion/features/login_settings/data/models/custom_header.dart';
class HeaderItem extends StatelessWidget {
final int index;
final CustomHeaderModel header;
final bool isLast;
const HeaderItem({
super.key,
required this.index,
required this.header,
required this.isLast,
});
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'${localizations.header} ${index + 1}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const Spacer(),
IconButton(
icon: Icon(
Icons.delete,
color: Theme.of(context).colorScheme.error,
size: 20,
),
onPressed: () {
context.read<LoginSettingsBloc>().add(
DeleteCustomHeader(index),
);
},
tooltip: localizations.deleteHeader,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minHeight: 36, minWidth: 36),
),
],
),
const SizedBox(height: 8),
_buildTextField(
context: context,
initialValue: header.key,
labelText: localizations.headerKey,
onChanged: (newKey) {
context.read<LoginSettingsBloc>().add(
UpdateCustomHeaderKey(index, newKey),
);
},
),
const SizedBox(height: 12),
_buildTextField(
context: context,
initialValue: header.value,
labelText: localizations.headerValue,
onChanged: (newValue) {
context.read<LoginSettingsBloc>().add(
UpdateCustomHeaderValue(index, newValue),
);
},
),
if (!isLast)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Divider(color: Theme.of(context).colorScheme.outlineVariant),
)
else
const SizedBox(height: 8),
],
);
}
Widget _buildTextField({
required BuildContext context,
String? initialValue,
required String labelText,
IconData? prefixIcon,
String? hintText,
bool obscureText = false,
Function(String)? onChanged,
}) {
return TextFormField(
initialValue: initialValue,
obscureText: obscureText,
onChanged: onChanged,
decoration: InputDecoration(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12.0)),
labelText: labelText,
hintText: hintText,
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 14.0,
),
),
);
}
}
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:calibre_web_companion/features/login_settings/bloc/login_settings_bloc.dart';
import 'package:calibre_web_companion/features/login_settings/bloc/login_settings_state.dart';
import 'package:calibre_web_companion/features/login_settings/presentation/widgets/header_item_widget.dart';
class HeadersSection extends StatelessWidget {
const HeadersSection({super.key});
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
BorderRadius borderRadius = BorderRadius.circular(8.0);
return BlocBuilder<LoginSettingsBloc, LoginSettingsState>(
builder: (context, state) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 3,
shape: RoundedRectangleBorder(borderRadius: borderRadius),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.code_rounded,
size: 28,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizations.httpHeader,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
localizations
.addACostumHttpHeaderThatWillBeSentWithEveryRequest,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
const SizedBox(height: 24),
// Header List
if (state.customHeaders.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Center(
child: Text(
localizations.noCostumHttpHeadersYet,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
)
else
...state.customHeaders.asMap().entries.map((entry) {
final index = entry.key;
final header = entry.value;
return HeaderItem(
index: index,
header: header,
isLast: index == state.customHeaders.length - 1,
);
}),
],
),
),
);
},
);
}
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:calibre_web_companion/features/me/bloc/me_event.dart';
import 'package:calibre_web_companion/features/me/bloc/me_state.dart';
import 'package:calibre_web_companion/features/me/data/repositories/me_repositorie.dart';
class MeBloc extends Bloc<MeEvent, MeState> {
final MeRepository repository;
MeBloc({required this.repository}) : super(const MeState()) {
on<LoadStats>(_onLoadStats);
on<LogOut>(_onLogOut);
}
Future<void> _onLoadStats(LoadStats event, Emitter<MeState> emit) async {
emit(state.copyWith(status: MeStatus.loading));
try {
final stats = await repository.getStats();
emit(
state.copyWith(
status: MeStatus.loaded,
stats: stats,
errorMessage: null,
),
);
} catch (e) {
emit(state.copyWith(status: MeStatus.error, errorMessage: e.toString()));
}
}
Future<void> _onLogOut(LogOut event, Emitter<MeState> emit) async {
emit(state.copyWith(logoutStatus: LogoutStatus.loading));
try {
await repository.logOut();
emit(state.copyWith(logoutStatus: LogoutStatus.success));
} catch (e) {
emit(state.copyWith(status: MeStatus.error, errorMessage: e.toString()));
}
}
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:equatable/equatable.dart';
abstract class MeEvent extends Equatable {
const MeEvent();
@override
List<Object?> get props => [];
}
class LoadStats extends MeEvent {
const LoadStats();
}
class LogOut extends MeEvent {
const LogOut();
}
class NavigateToSettings extends MeEvent {
const NavigateToSettings();
}
class NavigateToShelves extends MeEvent {
const NavigateToShelves();
}
class NavigateToReadBooks extends MeEvent {
const NavigateToReadBooks();
}
class NavigateToUnreadBooks extends MeEvent {
const NavigateToUnreadBooks();
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:equatable/equatable.dart';
import 'package:calibre_web_companion/features/me/data/models/stats_model.dart';
enum MeStatus { initial, loading, loaded, error }
enum LogoutStatus { initial, loading, success, error }
class MeState extends Equatable {
final MeStatus status;
final LogoutStatus logoutStatus;
final StatsModel? stats;
final String? errorMessage;
const MeState({
this.status = MeStatus.initial,
this.logoutStatus = LogoutStatus.initial,
this.stats,
this.errorMessage,
});
MeState copyWith({
MeStatus? status,
StatsModel? stats,
String? errorMessage,
LogoutStatus? logoutStatus,
}) {
return MeState(
status: status ?? this.status,
logoutStatus: logoutStatus ?? this.logoutStatus,
stats: stats ?? this.stats,
errorMessage: errorMessage,
);
}
@override
List<Object?> get props => [status, logoutStatus, stats, errorMessage];
}
@@ -0,0 +1,28 @@
import 'package:calibre_web_companion/core/services/api_service.dart';
import 'package:calibre_web_companion/features/me/data/models/stats_model.dart';
class MeDataSource {
final ApiService apiService;
MeDataSource({required this.apiService});
Future<StatsModel> getStats() async {
try {
final jsonData = await apiService.getJson(
'/opds/stats',
AuthMethod.basic,
);
return StatsModel.fromJson(jsonData);
} catch (e) {
throw Exception('Failed to load stats: $e');
}
}
Future<void> logOut() async {
try {
await apiService.get('/logout', AuthMethod.cookie);
} catch (e) {
throw Exception('Failed to logout: $e');
}
}
}
@@ -0,0 +1,27 @@
import 'package:equatable/equatable.dart';
class StatsModel extends Equatable {
final int books;
final int authors;
final int categories;
final int series;
const StatsModel({
this.books = 0,
this.authors = 0,
this.categories = 0,
this.series = 0,
});
factory StatsModel.fromJson(Map<String, dynamic> json) {
return StatsModel(
books: json['books'],
authors: json['authors'],
categories: json['categories'],
series: json['series'],
);
}
@override
List<Object?> get props => [books, authors, categories, series];
}
@@ -0,0 +1,25 @@
import 'package:calibre_web_companion/features/me/data/datasources/me_datasource.dart';
import 'package:calibre_web_companion/features/me/data/models/stats_model.dart';
class MeRepository {
final MeDataSource dataSource;
MeRepository({required this.dataSource});
Future<StatsModel> getStats() async {
try {
final stats = await dataSource.getStats();
return stats;
} catch (e) {
throw Exception('Failed to load stats: $e');
}
}
Future<void> logOut() async {
try {
await dataSource.logOut();
} catch (e) {
throw Exception('Failed to logout: $e');
}
}
}
@@ -0,0 +1,149 @@
import 'package:calibre_web_companion/features/shelf_view.dart/presentation/pages/shelf_view_page.dart';
import 'package:calibre_web_companion/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:calibre_web_companion/features/me/bloc/me_bloc.dart';
import 'package:calibre_web_companion/features/me/bloc/me_event.dart';
import 'package:calibre_web_companion/features/me/bloc/me_state.dart';
import 'package:calibre_web_companion/features/me/data/models/stats_model.dart';
import 'package:calibre_web_companion/core/services/app_transition.dart';
import 'package:calibre_web_companion/core/services/snackbar.dart';
import 'package:calibre_web_companion/features/me/presentation/widgets/stats_card_widget.dart';
import 'package:calibre_web_companion/shared/widgets/long_button_widget.dart';
import 'package:calibre_web_companion/features/login/presentation/pages/login_page.dart';
class MePage extends StatelessWidget {
const MePage({super.key});
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
return BlocProvider(
create: (context) => getIt<MeBloc>()..add(const LoadStats()),
child: BlocConsumer<MeBloc, MeState>(
listener: (context, state) {
if (state.status == MeStatus.error) {
context.showSnackBar(
"${localizations.error}: ${state.errorMessage}",
isError: true,
);
}
if (state.logoutStatus == LogoutStatus.success) {
_handleLogout(context);
} else if (state.logoutStatus == LogoutStatus.error) {
context.showSnackBar(
"${localizations.logoutFailed}: ${state.errorMessage}",
isError: true,
);
}
},
builder: (context, state) {
return Scaffold(
appBar: AppBar(
title: Text(localizations.me),
actions: [
IconButton(
onPressed: () {
context.read<MeBloc>().add(const LogOut());
},
icon: const Icon(Icons.logout),
tooltip: localizations.logout,
),
],
),
body: RefreshIndicator(
onRefresh: () async {
context.read<MeBloc>().add(const LoadStats());
return;
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
children: [
StatsCard(
stats: state.stats ?? const StatsModel(),
isLoading: state.status == MeStatus.loading,
errorMessage:
state.status == MeStatus.error
? state.errorMessage
: null,
onRetry:
() => context.read<MeBloc>().add(const LoadStats()),
),
LongButton(
text: localizations.settings,
icon: Icons.settings_rounded,
onPressed: () {},
// TODO: Implement settings navigation
// () => Navigator.of(context).push(
// AppTransitions.createSlideRoute(
// const SettingsPage(),
// ),
// ),
),
LongButton(
text: localizations.shelfs,
icon: Icons.list_rounded,
onPressed:
() => Navigator.of(context).push(
AppTransitions.createSlideRoute(ShelfViewPage()),
),
),
LongButton(
text: localizations.showReadBooks,
icon: Icons.my_library_books_rounded,
onPressed: () {},
// TODO: Implement read books navigation
// () => Navigator.of(context).push(
// AppTransitions.createSlideRoute(
// BookListPage(
// title: localizations.readBooks,
// categoryType: CategoryType.readBooks,
// fullPath: "/opds/readbooks",
// ),
// ),
// ),
),
LongButton(
text: localizations.showUnReadBooks,
icon: Icons.read_more_rounded,
onPressed: () {},
// TODO : Implement unread books navigation
// () => Navigator.of(context).push(
// AppTransitions.createSlideRoute(
// BookListPage(
// title: localizations.unreadBooks,
// categoryType: CategoryType.unreadBooks,
// fullPath: "/opds/unreadbooks",
// ),
// ),
// ),
),
],
),
),
),
);
},
),
);
}
Future<void> _handleLogout(BuildContext context) async {
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.remove("calibre_web_session");
// ignore: use_build_context_synchronously
Navigator.of(
// ignore: use_build_context_synchronously
context,
).pushReplacement(AppTransitions.createSlideRoute(const LoginPage()));
}
}
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
class AnimatedCounter extends StatefulWidget {
final String value;
final TextStyle? style;
const AnimatedCounter({super.key, required this.value, this.style});
@override
State<AnimatedCounter> createState() => _AnimatedCounterState();
}
class _AnimatedCounterState extends State<AnimatedCounter>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
int _finalValue = 0;
@override
void initState() {
super.initState();
_finalValue = int.tryParse(widget.value) ?? 0;
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
_animation = Tween<double>(
begin: 0,
end: _finalValue.toDouble(),
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
_controller.forward();
}
@override
void didUpdateWidget(AnimatedCounter oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != oldWidget.value) {
final newValue = int.tryParse(widget.value) ?? 0;
_animation = Tween<double>(
begin: _finalValue.toDouble(),
end: newValue.toDouble(),
).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
);
_finalValue = newValue;
_controller.forward(from: 0);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Text(_animation.value.toInt().toString(), style: widget.style);
},
);
}
}
@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:calibre_web_companion/features/me/presentation/widgets/animated_counter_widget.dart';
import 'package:calibre_web_companion/features/me/data/models/stats_model.dart';
class StatsCard extends StatelessWidget {
final StatsModel stats;
final bool isLoading;
final String? errorMessage;
final VoidCallback onRetry;
const StatsCard({
super.key,
required this.stats,
required this.isLoading,
this.errorMessage,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
return Card(
margin: const EdgeInsets.all(16),
elevation: 3,
child: Skeletonizer(
enabled: isLoading,
containersColor: Theme.of(context).colorScheme.surface,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHighest,
highlightColor: Theme.of(context).colorScheme.surface,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
color: Theme.of(context).colorScheme.primaryContainer,
),
child: Text(
localizations.libraryStatistics,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildStatRow(
context,
Icons.book,
localizations.books,
stats.books.toString(),
),
const Divider(),
_buildStatRow(
context,
Icons.person,
localizations.authors,
stats.authors.toString(),
),
const Divider(),
_buildStatRow(
context,
Icons.category,
localizations.categories,
stats.categories.toString(),
),
const Divider(),
_buildStatRow(
context,
Icons.collections_bookmark,
localizations.series,
stats.series.toString(),
),
],
),
),
if (errorMessage != null && !isLoading)
Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(localizations.retry),
),
),
),
],
),
),
);
}
Widget _buildStatRow(
BuildContext context,
IconData icon,
String label,
String value,
) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Icon(icon, size: 28, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 16),
Expanded(
child: Text(label, style: Theme.of(context).textTheme.titleMedium),
),
AnimatedCounter(
value: value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
);
}
}
@@ -0,0 +1,174 @@
import 'package:calibre_web_companion/features/shelf_details/data/repositories/shelf_details_repositorie.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/bloc/shelf_view_bloc.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/bloc/shelf_view_event.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:calibre_web_companion/features/shelf_details/bloc/shelf_details_event.dart';
import 'package:calibre_web_companion/features/shelf_details/bloc/shelf_details_state.dart';
import 'package:path/path.dart';
class ShelfDetailsBloc extends Bloc<ShelfDetailsEvent, ShelfDetailsState> {
final ShelfDetailsRepository repository;
final ShelfViewBloc shelfViewBloc;
ShelfDetailsBloc({required this.repository, required this.shelfViewBloc})
: super(const ShelfDetailsState()) {
on<LoadShelfDetails>(_onLoadShelfDetails);
on<RemoveFromShelf>(_onRemoveFromShelf);
on<EditShelf>(_onEditShelf);
on<DeleteShelf>(_onDeleteShelf);
}
Future<void> _onLoadShelfDetails(
LoadShelfDetails event,
Emitter<ShelfDetailsState> emit,
) async {
emit(state.copyWith(status: ShelfDetailsStatus.loading));
try {
final result = await repository.getShelfDetails(event.shelfId);
emit(
state.copyWith(
status: ShelfDetailsStatus.loaded,
currentShelfDetail: result,
errorMessage: null,
),
);
} catch (e) {
emit(
state.copyWith(
status: ShelfDetailsStatus.error,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onRemoveFromShelf(
RemoveFromShelf event,
Emitter<ShelfDetailsState> emit,
) async {
emit(state.copyWith(actionDetailsStatus: ShelfDetailsActionStatus.loading));
try {
final success = await repository.removeFromShelf(
event.shelfId,
event.bookId,
);
if (success) {
emit(
state.copyWith(
actionDetailsStatus: ShelfDetailsActionStatus.success,
actionMessage: 'Book removed from shelf successfully',
),
);
shelfViewBloc.add(RemoveShelfFromState(event.shelfId));
} else {
emit(
state.copyWith(
actionDetailsStatus: ShelfDetailsActionStatus.error,
actionMessage: 'Failed to remove book from shelf',
),
);
}
} catch (e) {
emit(
state.copyWith(
actionDetailsStatus: ShelfDetailsActionStatus.error,
actionMessage: e.toString(),
),
);
return;
}
}
Future<void> _onEditShelf(
EditShelf event,
Emitter<ShelfDetailsState> emit,
) async {
emit(
state.copyWith(
actionDetailsStatus: ShelfDetailsActionStatus.loading,
actionMessage: null,
),
);
try {
final success = await repository.editShelf(
event.shelfId,
event.newShelfName,
);
if (success) {
emit(
state.copyWith(
currentShelfDetail: state.currentShelfDetail!.copyWith(
name: event.newShelfName,
),
actionDetailsStatus: ShelfDetailsActionStatus.success,
actionMessage: 'Shelf edited successfully',
),
);
shelfViewBloc.add(EditShelfState(event.shelfId, event.newShelfName));
} else {
emit(
state.copyWith(
actionDetailsStatus: ShelfDetailsActionStatus.error,
actionMessage: 'Failed to edit shelf',
),
);
}
} catch (e) {
emit(
state.copyWith(
actionDetailsStatus: ShelfDetailsActionStatus.error,
actionMessage: e.toString(),
),
);
return;
}
}
Future<void> _onDeleteShelf(
DeleteShelf event,
Emitter<ShelfDetailsState> emit,
) async {
emit(
state.copyWith(
actionDetailsStatus: ShelfDetailsActionStatus.loading,
actionMessage: null,
),
);
try {
final success = await repository.deleteShelf(event.shelfId);
if (success) {
emit(
state.copyWith(
actionDetailsStatus: ShelfDetailsActionStatus.success,
actionMessage: 'Shelf deleted successfully',
),
);
} else {
emit(
state.copyWith(
actionDetailsStatus: ShelfDetailsActionStatus.error,
actionMessage: 'Failed to delete shelf',
),
);
}
} catch (e) {
emit(
state.copyWith(
actionDetailsStatus: ShelfDetailsActionStatus.error,
actionMessage: e.toString(),
),
);
return;
}
}
}
@@ -0,0 +1,57 @@
import 'package:equatable/equatable.dart';
import 'package:calibre_web_companion/features/shelf_details/data/models/shelf_book_item_model.dart';
abstract class ShelfDetailsEvent extends Equatable {
const ShelfDetailsEvent();
@override
List<Object?> get props => [];
}
class LoadShelfDetails extends ShelfDetailsEvent {
final String shelfId;
const LoadShelfDetails(this.shelfId);
@override
List<Object?> get props => [shelfId];
}
class RemoveFromShelf extends ShelfDetailsEvent {
final String shelfId;
final String bookId;
const RemoveFromShelf(this.shelfId, this.bookId);
@override
List<Object?> get props => [shelfId, bookId];
}
class EditShelf extends ShelfDetailsEvent {
final String shelfId;
final String newShelfName;
const EditShelf(this.shelfId, this.newShelfName);
@override
List<Object?> get props => [shelfId, newShelfName];
}
class DeleteShelf extends ShelfDetailsEvent {
final String shelfId;
const DeleteShelf(this.shelfId);
@override
List<Object?> get props => [shelfId];
}
class NavigateToBook extends ShelfDetailsEvent {
final ShelfBookItem book;
const NavigateToBook(this.book);
@override
List<Object?> get props => [book];
}
@@ -0,0 +1,48 @@
import 'package:equatable/equatable.dart';
import 'package:calibre_web_companion/features/shelf_details/data/models/shelf_details_model.dart';
enum ShelfDetailsStatus { initial, loading, loaded, error }
enum ShelfDetailsActionStatus { initial, loading, success, error }
class ShelfDetailsState extends Equatable {
final ShelfDetailsStatus status;
final ShelfDetailsModel? currentShelfDetail;
final String? errorMessage;
final ShelfDetailsActionStatus actionDetailsStatus;
final String? actionMessage;
const ShelfDetailsState({
this.status = ShelfDetailsStatus.initial,
this.currentShelfDetail,
this.errorMessage,
this.actionDetailsStatus = ShelfDetailsActionStatus.initial,
this.actionMessage,
});
ShelfDetailsState copyWith({
ShelfDetailsStatus? status,
ShelfDetailsModel? currentShelfDetail,
String? errorMessage,
ShelfDetailsActionStatus? actionDetailsStatus,
String? actionMessage,
}) {
return ShelfDetailsState(
status: status ?? this.status,
currentShelfDetail: currentShelfDetail ?? this.currentShelfDetail,
errorMessage: errorMessage,
actionDetailsStatus: actionDetailsStatus ?? this.actionDetailsStatus,
actionMessage: actionMessage,
);
}
@override
List<Object?> get props => [
status,
currentShelfDetail,
errorMessage,
actionDetailsStatus,
actionMessage,
];
}
@@ -0,0 +1,99 @@
import 'package:logger/logger.dart';
import 'package:calibre_web_companion/core/services/api_service.dart';
import 'package:calibre_web_companion/features/shelf_details/data/models/shelf_details_model.dart';
class ShelfDetailsDataSource {
final ApiService apiService;
final Logger _logger = Logger();
ShelfDetailsDataSource({required this.apiService});
Future<ShelfDetailsModel> getShelfDetails(String shelfId) async {
try {
final path = '/shelf/$shelfId';
final response = await apiService.get(path, AuthMethod.cookie);
if (response.statusCode == 200) {
return ShelfDetailsModel.fromHtml(response.body);
} else {
throw Exception('Failed to get shelf details: ${response.statusCode}');
}
} catch (e) {
_logger.e('Error getting shelf details: $e');
throw Exception('Failed to get shelf details: $e');
}
}
Future<bool> removeFromShelf(String shelfId, String bookId) async {
try {
final response = await apiService.post(
'/shelf/remove/$shelfId/$bookId',
{},
{},
AuthMethod.cookie,
useCsrf: true,
contentType: 'application/x-www-form-urlencoded',
);
return response.statusCode == 204;
} catch (e) {
_logger.e('Error removing from shelf: $e');
throw Exception('Failed to remove from shelf: $e');
}
}
Future<bool> createShelf(String shelfName) async {
try {
final response = await apiService.post(
'/shelf/create',
{},
{'title': shelfName},
AuthMethod.cookie,
useCsrf: true,
contentType: 'application/x-www-form-urlencoded',
);
return response.statusCode == 302;
} catch (e) {
_logger.e('Error creating shelf: $e');
throw Exception('Failed to create shelf: $e');
}
}
Future<bool> editShelf(String shelfId, String newShelfName) async {
try {
final response = await apiService.post(
'/shelf/edit/$shelfId',
{},
{'title': newShelfName},
AuthMethod.cookie,
useCsrf: true,
contentType: 'application/x-www-form-urlencoded',
);
return response.statusCode == 302;
} catch (e) {
_logger.e('Error editing shelf: $e');
throw Exception('Failed to edit shelf: $e');
}
}
Future<bool> deleteShelf(String shelfId) async {
try {
final response = await apiService.post(
'/shelf/delete/$shelfId',
{},
{},
AuthMethod.cookie,
useCsrf: true,
contentType: 'application/x-www-form-urlencoded',
);
return response.statusCode == 302;
} catch (e) {
_logger.e('Error deleting shelf: $e');
throw Exception('Failed to delete shelf: $e');
}
}
}
@@ -0,0 +1,11 @@
import 'package:equatable/equatable.dart';
class BookAuthor extends Equatable {
final String name;
final String id;
const BookAuthor({required this.name, required this.id});
@override
List<Object?> get props => [name, id];
}
@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import 'package:calibre_web_companion/features/shelf_details/data/models/book_author_model.dart';
class ShelfBookItem extends Equatable {
final String id;
final String title;
final List<BookAuthor> authors;
final String? seriesName;
final String? seriesId;
final String? seriesIndex;
const ShelfBookItem({
required this.id,
required this.title,
required this.authors,
this.seriesName,
this.seriesId,
this.seriesIndex,
});
@override
List<Object?> get props => [
id,
title,
authors,
seriesName,
seriesId,
seriesIndex,
];
}
@@ -0,0 +1,105 @@
import 'package:equatable/equatable.dart';
import 'package:html/parser.dart' as html_parser;
import 'package:html/dom.dart' as html;
import 'package:calibre_web_companion/features/shelf_details/data/models/shelf_book_item_model.dart';
import 'package:calibre_web_companion/features/shelf_details/data/models/book_author_model.dart';
class ShelfDetailsModel extends Equatable {
final String name;
final List<ShelfBookItem> books;
const ShelfDetailsModel({required this.name, required this.books});
factory ShelfDetailsModel.fromHtml(String htmlContent) {
final document = html_parser.parse(htmlContent);
return ShelfDetailsModel(
name: _extractShelfName(document),
books: _extractBooks(document),
);
}
static String _extractShelfName(html.Document document) {
return document
.querySelector('h2')
?.text
.replaceAll(RegExp(r"^[^']*'|'[^']*$"), '') ??
"Unknown Shelf";
}
static List<ShelfBookItem> _extractBooks(html.Document document) {
return document.querySelectorAll('.book').map(_extractBookItem).toList();
}
static ShelfBookItem _extractBookItem(html.Element bookElement) {
final seriesInfo = _extractSeriesInfo(bookElement);
return ShelfBookItem(
id: _extractBookId(bookElement),
title: _extractTitle(bookElement),
authors: _extractAuthors(bookElement),
seriesName: seriesInfo['name'],
seriesId: seriesInfo['id'],
seriesIndex: seriesInfo['index'],
);
}
static String _extractTitle(html.Element bookElement) {
return bookElement.querySelector('.title')?.text ?? "Unknown Title";
}
static List<BookAuthor> _extractAuthors(html.Element bookElement) {
return bookElement
.querySelectorAll('.author a')
.map(
(link) => BookAuthor(
name: link.text,
id: _extractIdFromUrl(link.attributes['href'] ?? ''),
),
)
.toList();
}
static Map<String, String?> _extractSeriesInfo(html.Element bookElement) {
final seriesElement = bookElement.querySelector('.series');
final seriesLink = seriesElement?.querySelector('a');
if (seriesLink == null) {
return {'name': null, 'id': null, 'index': null};
}
final seriesIndex = RegExp(
r'\((\d+(?:\.\d+)?)\)',
).firstMatch(seriesElement!.text)?.group(1);
return {
'name': seriesLink.text.trim(),
'id': _extractIdFromUrl(seriesLink.attributes['href'] ?? ''),
'index': seriesIndex,
};
}
static String _extractBookId(html.Element bookElement) {
final href =
bookElement.querySelector('a[data-toggle="modal"]')?.attributes['href'];
return RegExp(r'/book/(\d+)').firstMatch(href ?? '')?.group(1) ?? '';
}
static String _extractIdFromUrl(String url) {
return url
.split('/')
.lastWhere((part) => part.isNotEmpty, orElse: () => '');
}
ShelfDetailsModel copyWith({String? name, List<ShelfBookItem>? books}) {
return ShelfDetailsModel(
name: name ?? this.name,
books: books ?? this.books,
);
}
@override
List<Object?> get props => [name, books];
}
@@ -0,0 +1,44 @@
import 'package:calibre_web_companion/features/shelf_details/data/datasources/shelf_details_datasource.dart';
import 'package:calibre_web_companion/features/shelf_details/data/models/shelf_details_model.dart';
class ShelfDetailsRepository {
final ShelfDetailsDataSource dataSource;
ShelfDetailsRepository({required this.dataSource});
Future<ShelfDetailsModel> getShelfDetails(String shelfId) async {
try {
final shelfDetails = await dataSource.getShelfDetails(shelfId);
return shelfDetails;
} catch (e) {
throw Exception('Failed to fetch shelf details: $e');
}
}
Future<bool> removeFromShelf(String shelfId, String bookId) async {
try {
final result = await dataSource.removeFromShelf(shelfId, bookId);
return result;
} catch (e) {
throw Exception('Failed to remove book from shelf: $e');
}
}
Future<bool> editShelf(String shelfId, String newShelfName) async {
try {
final result = await dataSource.editShelf(shelfId, newShelfName);
return result;
} catch (e) {
throw Exception('Failed to edit shelf: $e');
}
}
Future<bool> deleteShelf(String shelfId) async {
try {
final result = await dataSource.deleteShelf(shelfId);
return result;
} catch (e) {
throw Exception('Failed to delete shelf: $e');
}
}
}
@@ -0,0 +1,448 @@
import 'package:calibre_web_companion/features/shelf_view.dart/bloc/shelf_view_bloc.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/bloc/shelf_view_event.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:calibre_web_companion/features/shelf_details/bloc/shelf_details_bloc.dart';
import 'package:calibre_web_companion/features/shelf_details/bloc/shelf_details_event.dart';
import 'package:calibre_web_companion/features/shelf_details/bloc/shelf_details_state.dart';
import 'package:calibre_web_companion/core/services/snackbar.dart';
import 'package:calibre_web_companion/main.dart';
import 'package:calibre_web_companion/features/shelf_details/data/models/book_author_model.dart';
import 'package:calibre_web_companion/features/shelf_details/data/models/shelf_book_item_model.dart';
import 'package:calibre_web_companion/features/shelf_details/data/models/shelf_details_model.dart';
import 'package:calibre_web_companion/features/shelf_details/presentation/widgets/edit_shelf_dialog_widget.dart';
class ShelfDetailsPage extends StatelessWidget {
final String shelfId;
const ShelfDetailsPage({super.key, required this.shelfId});
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
return BlocProvider(
create:
(context) =>
getIt<ShelfDetailsBloc>()..add(LoadShelfDetails(shelfId)),
child: BlocConsumer<ShelfDetailsBloc, ShelfDetailsState>(
listener: (context, state) {
if (state.actionDetailsStatus == ShelfDetailsActionStatus.success) {
if (state.actionMessage?.contains('deleted') == true) {
Navigator.of(context).pop();
}
context.showSnackBar(state.actionMessage!, isError: false);
} else if (state.actionDetailsStatus ==
ShelfDetailsActionStatus.error) {
context.showSnackBar(state.actionMessage!, isError: true);
}
if (state.status == ShelfDetailsStatus.error) {
context.showSnackBar(
"${localizations.errorLoadingData}: ${state.errorMessage}",
isError: true,
);
}
},
builder: (context, state) {
return Scaffold(
appBar: AppBar(
title: Text(
state.currentShelfDetail?.name ?? localizations.loading,
),
actions: [
IconButton(
icon: CircleAvatar(
backgroundColor:
Theme.of(context).colorScheme.secondaryContainer,
child: const Icon(Icons.edit_rounded),
),
tooltip: localizations.editShelf,
onPressed:
() => _showEditShelfDialog(context, state, localizations),
),
IconButton(
icon: CircleAvatar(
backgroundColor:
Theme.of(context).colorScheme.secondaryContainer,
child: const Icon(Icons.delete_rounded),
),
tooltip: localizations.deleteShelf,
onPressed:
() =>
_showDeleteShelfDialog(context, state, localizations),
),
],
),
body: _buildBody(context, state, localizations),
);
},
),
);
}
Widget _buildBody(
BuildContext context,
ShelfDetailsState state,
AppLocalizations localizations,
) {
if (state.status == ShelfDetailsStatus.loading) {
return _buildLoadingSkeleton(context, localizations);
}
if (state.status == ShelfDetailsStatus.error) {
return _buildErrorWidget(context, state, localizations);
}
if (state.currentShelfDetail == null) {
return _buildEmptyState(context, localizations);
}
if (state.currentShelfDetail!.books.isEmpty) {
return _buildEmptyShelfState(context, localizations);
}
return _buildBookGrid(context, state.currentShelfDetail!, localizations);
}
Widget _buildLoadingSkeleton(
BuildContext context,
AppLocalizations localizations,
) {
final dummyBooks = List.generate(
6,
(index) => ShelfBookItem(
id: 'dummy-$index',
title: 'Loading Book Title',
authors: [BookAuthor(name: 'Loading Author', id: 'author-id')],
seriesName: index % 2 == 0 ? 'Loading Series' : null,
seriesIndex: index % 2 == 0 ? '1' : null,
),
);
final dummyShelf = ShelfDetailsModel(
name: 'Loading Shelf...',
books: dummyBooks,
);
return Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHighest,
highlightColor: Theme.of(context).colorScheme.surface,
),
child: _buildBookGrid(context, dummyShelf, localizations),
);
}
Widget _buildErrorWidget(
BuildContext context,
ShelfDetailsState state,
AppLocalizations localizations,
) {
return RefreshIndicator(
onRefresh: () async {
context.read<ShelfDetailsBloc>().add(LoadShelfDetails(shelfId));
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(height: MediaQuery.of(context).size.height / 3),
Center(
child: Column(
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
localizations.errorLoadingData,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(state.errorMessage ?? localizations.unknownError),
const SizedBox(height: 16),
ElevatedButton(
onPressed:
() => context.read<ShelfDetailsBloc>().add(
LoadShelfDetails(shelfId),
),
child: Text(localizations.tryAgain),
),
],
),
),
],
),
);
}
Widget _buildEmptyState(
BuildContext context,
AppLocalizations localizations,
) {
return RefreshIndicator(
onRefresh: () async {
context.read<ShelfDetailsBloc>().add(LoadShelfDetails(shelfId));
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(height: MediaQuery.of(context).size.height / 3),
Center(
child: Column(
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
localizations.shelfNotFound,
style: Theme.of(context).textTheme.titleLarge,
),
],
),
),
],
),
);
}
Widget _buildEmptyShelfState(
BuildContext context,
AppLocalizations localizations,
) {
return RefreshIndicator(
onRefresh: () async {
context.read<ShelfDetailsBloc>().add(LoadShelfDetails(shelfId));
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(height: MediaQuery.of(context).size.height / 3),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.auto_stories_rounded,
size: 80,
color: Theme.of(
context,
).colorScheme.secondary.withValues(alpha: .5),
),
const SizedBox(height: 24),
Text(
localizations.shelfIsEmpty,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: Text(
localizations.addBooksToShelf,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: .7),
),
textAlign: TextAlign.center,
),
),
],
),
],
),
);
}
Widget _buildBookGrid(
BuildContext context,
ShelfDetailsModel shelf,
AppLocalizations localizations,
) {
return RefreshIndicator(
onRefresh: () async {
context.read<ShelfDetailsBloc>().add(LoadShelfDetails(shelfId));
},
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizations.shelfContains(shelf.books.length),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
const Divider(),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.6,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
),
delegate: SliverChildBuilderDelegate(
(context, index) =>
_buildBookItem(context, shelf.books[index], localizations),
childCount: shelf.books.length,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
);
}
Widget _buildBookItem(
BuildContext context,
ShelfBookItem book,
AppLocalizations localizations,
) {
return Card(
elevation: 4.0,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {},
// TODO: Implement navigation to book details page
// () => Navigator.of(context).push(
// AppTransitions.createSlideRoute(
// BookDetailsPage(bookUuid: book.id),
// ),
// ),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//Expanded(child: BookCoverImage(bookId: book.id)),
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 4),
Text(
book.authors.map((a) => a.name).join(', '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
if (book.seriesName != null)
Text(
'${book.seriesName} ${book.seriesIndex ?? ""}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
color: Theme.of(context).colorScheme.secondary,
),
),
],
),
),
],
),
),
);
}
void _showEditShelfDialog(
BuildContext context,
ShelfDetailsState state,
AppLocalizations localizations,
) {
if (state.currentShelfDetail == null) return;
showDialog(
context: context,
builder:
(dialogContext) => EditShelfDialog(
currentName: state.currentShelfDetail!.name,
onEditShelf: (newName) {
context.read<ShelfDetailsBloc>().add(EditShelf(shelfId, newName));
if (context.read<ShelfViewBloc>().state.shelves.isNotEmpty) {
context.read<ShelfViewBloc>().add(
EditShelfState(shelfId, newName),
);
}
},
),
);
}
void _showDeleteShelfDialog(
BuildContext context,
ShelfDetailsState state,
AppLocalizations localizations,
) {
if (state.currentShelfDetail == null) return;
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: Text(localizations.deleteShelf),
content: Text(
localizations.deleteShelfConfirmation(
state.currentShelfDetail!.name,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(localizations.cancel),
),
ElevatedButton(
onPressed: () {
Navigator.of(dialogContext).pop();
context.read<ShelfDetailsBloc>().add(DeleteShelf(shelfId));
if (context.read<ShelfViewBloc>().state.shelves.isNotEmpty) {
context.read<ShelfViewBloc>().add(
RemoveShelfFromState(shelfId),
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
child: Text(localizations.delete),
),
],
);
},
);
}
}
@@ -0,0 +1,97 @@
import 'package:calibre_web_companion/core/services/snackbar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class EditShelfDialog extends StatefulWidget {
final String currentName;
final Function(String) onEditShelf;
const EditShelfDialog({
super.key,
required this.currentName,
required this.onEditShelf,
});
@override
State<EditShelfDialog> createState() => _EditShelfDialogState();
}
class _EditShelfDialogState extends State<EditShelfDialog> {
late final TextEditingController _controller;
bool _isEditing = false;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.currentName);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(localizations.editShelf),
content: SizedBox(
width: double.maxFinite,
child: TextField(
controller: _controller,
decoration: InputDecoration(
labelText: localizations.shelfName,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.list_rounded),
),
autofocus: true,
enabled: !_isEditing,
),
),
actions: [
TextButton(
onPressed: _isEditing ? null : () => Navigator.of(context).pop(),
child: Text(localizations.cancel),
),
ElevatedButton(
onPressed: _isEditing ? null : _createShelf,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isEditing)
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onPrimary,
),
),
if (_isEditing) const SizedBox(width: 8),
Text(_isEditing ? localizations.editing : localizations.edit),
],
),
),
],
);
}
void _createShelf() {
final localizations = AppLocalizations.of(context)!;
if (_controller.text.trim().isEmpty) {
context.showSnackBar(localizations.shelfNameRequired, isError: true);
return;
}
setState(() {
_isEditing = true;
});
widget.onEditShelf(_controller.text.trim());
Navigator.of(context).pop();
}
}
@@ -0,0 +1,113 @@
import 'package:calibre_web_companion/features/shelf_view.dart/data/models/shelf_view_model.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/bloc/shelf_view_event.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/bloc/shelf_view_state.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/data/repositories/shelf_view_repositorie.dart';
class ShelfViewBloc extends Bloc<ShelfViewEvent, ShelfViewState> {
final ShelfViewRepository repository;
ShelfViewBloc({required this.repository}) : super(const ShelfViewState()) {
on<LoadShelves>(_onLoadShelves);
on<CreateShelf>(_onCreateShelf);
on<RemoveShelfFromState>(_onRemoveShelfFromState);
on<EditShelfState>(_onEditShelfState);
}
Future<void> _onLoadShelves(
LoadShelves event,
Emitter<ShelfViewState> emit,
) async {
emit(state.copyWith(createShelfStatus: CreateShelfStatus.initial));
emit(state.copyWith(status: ShelfViewStatus.loading));
try {
final shelves = await repository.loadShelves();
emit(
state.copyWith(
status: ShelfViewStatus.loaded,
shelves: shelves.shelves,
),
);
} catch (e) {
emit(
state.copyWith(
status: ShelfViewStatus.error,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onCreateShelf(
CreateShelf event,
Emitter<ShelfViewState> emit,
) async {
emit(state.copyWith(createShelfStatus: CreateShelfStatus.loading));
try {
final newShelfId = await repository.createShelf(event.shelfName);
final newShelf = ShelfViewModel(id: newShelfId, title: event.shelfName);
final updatedShelves = List.of(state.shelves)..add(newShelf);
emit(
state.copyWith(
createShelfStatus: CreateShelfStatus.success,
shelves: updatedShelves,
),
);
} catch (e) {
emit(
state.copyWith(
createShelfStatus: CreateShelfStatus.error,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onRemoveShelfFromState(
RemoveShelfFromState event,
Emitter<ShelfViewState> emit,
) async {
emit(state.copyWith(actionMessage: null));
final updatedShelves = List.of(state.shelves);
updatedShelves.removeWhere((shelf) => shelf.id == event.shelfId);
emit(
state.copyWith(
status: ShelfViewStatus.loaded,
shelves: updatedShelves,
actionMessage: 'Shelf removed successfully',
),
);
}
Future<void> _onEditShelfState(
EditShelfState event,
Emitter<ShelfViewState> emit,
) async {
emit(state.copyWith(actionMessage: null));
final updatedShelves =
state.shelves.map((shelf) {
if (shelf.id == event.shelfId) {
return shelf.copyWith(title: event.newShelfName);
}
return shelf;
}).toList();
emit(
state.copyWith(
status: ShelfViewStatus.loaded,
shelves: updatedShelves,
actionMessage: 'Shelf updated successfully',
),
);
}
}
@@ -0,0 +1,40 @@
import 'package:equatable/equatable.dart';
abstract class ShelfViewEvent extends Equatable {
const ShelfViewEvent();
@override
List<Object?> get props => [];
}
class LoadShelves extends ShelfViewEvent {
const LoadShelves();
}
class CreateShelf extends ShelfViewEvent {
final String shelfName;
const CreateShelf(this.shelfName);
@override
List<Object?> get props => [shelfName];
}
class RemoveShelfFromState extends ShelfViewEvent {
final String shelfId;
const RemoveShelfFromState(this.shelfId);
@override
List<Object?> get props => [shelfId];
}
class EditShelfState extends ShelfViewEvent {
final String shelfId;
final String newShelfName;
const EditShelfState(this.shelfId, this.newShelfName);
@override
List<Object?> get props => [shelfId, newShelfName];
}
@@ -0,0 +1,39 @@
import 'package:equatable/equatable.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/data/models/shelf_view_model.dart';
enum ShelfViewStatus { initial, loading, loaded, error }
enum CreateShelfStatus { initial, loading, success, error }
class ShelfViewState extends Equatable {
final ShelfViewStatus status;
final CreateShelfStatus createShelfStatus;
final List<ShelfViewModel> shelves;
final String? errorMessage;
const ShelfViewState({
this.status = ShelfViewStatus.initial,
this.createShelfStatus = CreateShelfStatus.initial,
this.shelves = const [],
this.errorMessage,
});
ShelfViewState copyWith({
ShelfViewStatus? status,
CreateShelfStatus? createShelfStatus,
List<ShelfViewModel>? shelves,
String? errorMessage,
String? actionMessage,
}) {
return ShelfViewState(
status: status ?? this.status,
createShelfStatus: createShelfStatus ?? this.createShelfStatus,
shelves: shelves ?? this.shelves,
errorMessage: errorMessage,
);
}
@override
List<Object?> get props => [status, shelves, errorMessage, createShelfStatus];
}
@@ -0,0 +1,49 @@
import 'package:logger/logger.dart';
import 'package:calibre_web_companion/core/services/api_service.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/data/models/shelf_list_view_model.dart';
class ShelfViewDataSource {
final ApiService apiService;
final Logger _logger = Logger();
ShelfViewDataSource({required this.apiService});
Future<ShelfListViewModel> loadShelves() async {
try {
final res = await apiService.getXmlAsJson(
'/opds/shelfindex',
AuthMethod.basic,
);
return ShelfListViewModel.fromFeedJson(res);
} catch (e) {
_logger.e("Error loading shelves: $e");
throw Exception('Failed to load shelves: $e');
}
}
Future<String> createShelf(String shelfName) async {
try {
final response = await apiService.post(
'/shelf/create',
{},
{'title': shelfName},
AuthMethod.cookie,
useCsrf: true,
contentType: 'application/x-www-form-urlencoded',
);
if (response.statusCode != 302) {
_logger.e('Failed to create shelf: ${response.body}');
throw Exception('Failed to create shelf: ${response.body}');
}
final shelfId = response.headers['location']!.split('/').last;
return shelfId;
} catch (e) {
_logger.e('Error creating shelf: $e');
throw Exception('Failed to create shelf: $e');
}
}
}
@@ -0,0 +1,26 @@
import 'package:equatable/equatable.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/data/models/shelf_view_model.dart';
class ShelfListViewModel extends Equatable {
final List<ShelfViewModel> shelves;
const ShelfListViewModel({required this.shelves});
factory ShelfListViewModel.fromFeedJson(Map<String, dynamic> json) {
final List<ShelfViewModel> shelves = [];
try {
for (var shelf in json['feed']['entry']) {
shelves.add(ShelfViewModel.fromJson(shelf));
}
return ShelfListViewModel(shelves: shelves);
} catch (e) {
return const ShelfListViewModel(shelves: []);
}
}
@override
List<Object?> get props => [shelves];
}
@@ -0,0 +1,19 @@
import 'package:equatable/equatable.dart';
class ShelfViewModel extends Equatable {
final String title;
final String id;
const ShelfViewModel({required this.title, required this.id});
factory ShelfViewModel.fromJson(Map<String, dynamic> json) {
return ShelfViewModel(title: json['title'], id: json['id'].split('/').last);
}
ShelfViewModel copyWith({String? title, String? id}) {
return ShelfViewModel(title: title ?? this.title, id: id ?? this.id);
}
@override
List<Object?> get props => [title, id];
}
@@ -0,0 +1,26 @@
import 'package:calibre_web_companion/features/shelf_view.dart/data/datasources/shelf_view_datasource.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/data/models/shelf_list_view_model.dart';
class ShelfViewRepository {
final ShelfViewDataSource dataSource;
ShelfViewRepository({required this.dataSource});
Future<ShelfListViewModel> loadShelves() async {
try {
final shelves = await dataSource.loadShelves();
return shelves;
} catch (e) {
throw Exception(e.toString());
}
}
Future<String> createShelf(String shelfName) async {
try {
final result = await dataSource.createShelf(shelfName);
return result;
} catch (e) {
throw Exception(e.toString());
}
}
}
@@ -0,0 +1,222 @@
import 'package:calibre_web_companion/features/shelf_details/bloc/shelf_details_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/bloc/shelf_view_bloc.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/bloc/shelf_view_event.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/bloc/shelf_view_state.dart';
import 'package:calibre_web_companion/core/services/snackbar.dart';
import 'package:calibre_web_companion/main.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/presentation/widgets/create_shelf_dialog_widget.dart';
import 'package:calibre_web_companion/core/services/app_transition.dart';
import 'package:calibre_web_companion/features/shelf_details/presentation/pages/shelf_details_page.dart';
class ShelfViewPage extends StatelessWidget {
const ShelfViewPage({super.key});
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
return BlocProvider(
create: (context) => getIt<ShelfViewBloc>()..add(const LoadShelves()),
child: BlocConsumer<ShelfViewBloc, ShelfViewState>(
listener: (context, state) {
if (state.createShelfStatus == CreateShelfStatus.success) {
context.showSnackBar(
localizations.shelfSuccessfullyCreated,
isError: false,
);
} else if (state.createShelfStatus == CreateShelfStatus.error) {
context.showSnackBar(state.errorMessage.toString(), isError: true);
}
if (state.status == ShelfViewStatus.error) {
context.showSnackBar(
"${localizations.errorLoadingData}: ${state.errorMessage}",
isError: true,
);
}
},
builder: (context, state) {
return Scaffold(
appBar: AppBar(title: Text(localizations.shelfs)),
body: RefreshIndicator(
onRefresh: () async {
context.read<ShelfViewBloc>().add(const LoadShelves());
},
child: _buildBody(context, state, localizations),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showCreateShelfDialog(context, localizations),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add_rounded),
const SizedBox(width: 8),
Text(localizations.createShelf),
],
),
),
);
},
),
);
}
Widget _buildBody(
BuildContext context,
ShelfViewState state,
AppLocalizations localizations,
) {
if (state.status == ShelfViewStatus.loading) {
return _buildLoadingSkeleton(context);
}
if (state.status == ShelfViewStatus.error) {
return _buildErrorWidget(context, state, localizations);
}
if (state.shelves.isEmpty) {
return _buildEmptyState(context, localizations);
}
return _buildShelfsList(context, state, localizations);
}
Widget _buildLoadingSkeleton(BuildContext context) {
return Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHighest,
highlightColor: Theme.of(context).colorScheme.surface,
),
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: 5,
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
leading: const Icon(Icons.list_rounded),
title: Text("Loading Shelf Title"),
trailing: const Icon(Icons.chevron_right_rounded),
),
);
},
),
);
}
Widget _buildErrorWidget(
BuildContext context,
ShelfViewState state,
AppLocalizations localizations,
) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
localizations.errorLoadingData,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(state.errorMessage ?? localizations.unknownError),
const SizedBox(height: 16),
ElevatedButton(
onPressed:
() => context.read<ShelfViewBloc>().add(const LoadShelves()),
child: Text(localizations.tryAgain),
),
],
),
);
}
Widget _buildEmptyState(
BuildContext context,
AppLocalizations localizations,
) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.list_rounded,
size: 64,
color: Theme.of(
context,
).colorScheme.secondary.withValues(alpha: .5),
),
const SizedBox(height: 16),
Text(
localizations.noShelvesFoundCreateOne,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
);
}
Widget _buildShelfsList(
BuildContext context,
ShelfViewState state,
AppLocalizations localizations,
) {
return ListView.builder(
itemCount: state.shelves.length,
itemBuilder: (context, index) {
final shelf = state.shelves[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
leading: const Icon(Icons.list_rounded),
title: Text(shelf.title),
trailing: const Icon(Icons.chevron_right_rounded),
onTap:
() => Navigator.of(context).push(
AppTransitions.createSlideRoute(
MultiBlocProvider(
providers: [
BlocProvider.value(
value: context.read<ShelfViewBloc>(),
),
BlocProvider(
create: (context) => getIt<ShelfDetailsBloc>(),
),
],
child: ShelfDetailsPage(shelfId: shelf.id),
),
),
),
),
);
},
);
}
void _showCreateShelfDialog(
BuildContext context,
AppLocalizations localizations,
) {
showDialog(
context: context,
builder:
(dialogContext) => CreateShelfDialog(
onCreateShelf: (shelfName) {
context.read<ShelfViewBloc>().add(CreateShelf(shelfName));
},
),
);
}
}
@@ -0,0 +1,86 @@
import 'package:calibre_web_companion/core/services/snackbar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class CreateShelfDialog extends StatefulWidget {
final Function(String) onCreateShelf;
const CreateShelfDialog({super.key, required this.onCreateShelf});
@override
State<CreateShelfDialog> createState() => _CreateShelfDialogState();
}
class _CreateShelfDialogState extends State<CreateShelfDialog> {
final _controller = TextEditingController();
bool _isCreating = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(localizations.createShelf),
content: SizedBox(
width: double.maxFinite,
child: TextField(
controller: _controller,
decoration: InputDecoration(
labelText: localizations.shelfName,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.list_rounded),
),
autofocus: true,
enabled: !_isCreating,
),
),
actions: [
TextButton(
onPressed: _isCreating ? null : () => Navigator.of(context).pop(),
child: Text(localizations.cancel),
),
ElevatedButton(
onPressed: _isCreating ? null : _createShelf,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isCreating)
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onPrimary,
),
),
if (_isCreating) const SizedBox(width: 8),
Text(_isCreating ? localizations.creating : localizations.create),
],
),
),
],
);
}
void _createShelf() {
final localizations = AppLocalizations.of(context)!;
if (_controller.text.trim().isEmpty) {
context.showSnackBar(localizations.shelfNameRequired, isError: true);
return;
}
setState(() {
_isCreating = true;
});
widget.onCreateShelf(_controller.text.trim());
Navigator.of(context).pop();
}
}
+4 -2
View File
@@ -146,7 +146,7 @@
"createShelf": "Regal erstellen",
"shelfName": "Regalname",
"shelfNameRequired": "Regalname ist erforderlich",
"shelfSuccessfullyCreated": "Regal {name} erfolgreich erstellt",
"shelfSuccessfullyCreated": "Regal erfolgreich erstellt",
"errorCreatingShelf": "Fehler beim Erstellen des Regals {name}",
"create": "Erstellen",
"creating": "Erstellen...",
@@ -290,5 +290,7 @@
"uploadingBook": "Buch hochladen",
"columnsCount": "Anzahl der Spalten",
"columns": "Spalten",
"uploadEbook": "E-Book hochladen"
"uploadEbook": "E-Book hochladen",
"logoutFailed": "Abmeldung fehlgeschlagen",
"shelfNotFound": "Regal nicht gefunden"
}
+4 -2
View File
@@ -144,7 +144,7 @@
"createShelf": "Create shelf",
"shelfName": "Shelf name",
"shelfNameRequired": "Shelf name is required",
"shelfSuccessfullyCreated": "Shelf {name} successfully created",
"shelfSuccessfullyCreated": "Shelf successfully created",
"errorCreatingShelf": "Error creating shelf {name}",
"create": "Create",
"creating": "Creating",
@@ -290,5 +290,7 @@
"uploadingBook": "Uploading book",
"columnsCount": "Columns count",
"columns": "Columns",
"uploadEbook": "Upload eBook"
"uploadEbook": "Upload eBook",
"logoutFailed": "Logout failed",
"shelfNotFound": "Shelf not found"
}
+4 -2
View File
@@ -146,7 +146,7 @@
"createShelf": "Crear estante",
"shelfName": "Nombre del estante",
"shelfNameRequired": "El nombre del estante es obligatorio",
"shelfSuccessfullyCreated": "Estante {name} creado con éxito",
"shelfSuccessfullyCreated": "Estante creado con éxito",
"errorCreatingShelf": "Error al crear el estante {name}",
"create": "Crear",
"creating": "Creando",
@@ -290,5 +290,7 @@
"uploadingBook": "Cargando libro",
"columnsCount": "Número de columnas",
"columns": "Columnas",
"uploadEbook": "Cargar libro electrónico"
"uploadEbook": "Cargar libro electrónico",
"logoutFailed": "Error al cerrar sesión",
"shelfNotFound": "Estante no encontrado"
}
+4 -2
View File
@@ -146,7 +146,7 @@
"createShelf": "Créer une étagère",
"shelfName": "Nom de l'étagère",
"shelfNameRequired": "Le nom de l'étagère est requis",
"shelfSuccessfullyCreated": "Étagère {name} créée avec succès",
"shelfSuccessfullyCreated": "Étagère créée avec succès",
"errorCreatingShelf": "Erreur lors de la création de l'étagère {name}",
"create": "Créer",
"creating": "Création",
@@ -290,5 +290,7 @@
"uploadingBook": "Téléchargement du livre",
"columnsCount": "Nombre de colonnes",
"columns": "Colonnes",
"uploadEbook": "Télécharger un eBook"
"uploadEbook": "Télécharger un eBook",
"logoutFailed": "Échec de la déconnexion",
"shelfNotFound": "Étagère non trouvée"
}
+211 -121
View File
@@ -1,69 +1,182 @@
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:calibre_web_companion/view_models/book_details_view_model.dart';
import 'package:calibre_web_companion/view_models/book_list_view_model.dart';
import 'package:calibre_web_companion/view_models/book_metadata_edit_view_model.dart';
import 'package:calibre_web_companion/view_models/book_recommendation_view_model.dart';
import 'package:calibre_web_companion/view_models/books_view_model.dart';
import 'package:calibre_web_companion/view_models/download_service_view_model.dart';
import 'package:calibre_web_companion/view_models/homepage_view_model.dart';
import 'package:calibre_web_companion/view_models/login_settings_view_model.dart';
import 'package:calibre_web_companion/view_models/login_view_model.dart';
import 'package:calibre_web_companion/view_models/main_view_model.dart';
import 'package:calibre_web_companion/view_models/me_view_model.dart';
import 'package:calibre_web_companion/view_models/settings_view_mode.dart';
import 'package:calibre_web_companion/view_models/shelf_view_model.dart';
import 'package:calibre_web_companion/views/homepage_view.dart';
import 'package:calibre_web_companion/views/login_view.dart';
import 'package:calibre_web_companion/features/book_view/bloc/book_view_bloc.dart';
import 'package:calibre_web_companion/features/book_view/bloc/book_view_event.dart';
import 'package:calibre_web_companion/features/book_view/data/datasources/book_view_datasource.dart';
import 'package:calibre_web_companion/features/book_view/data/repositories/book_view_repository.dart';
import 'package:calibre_web_companion/features/discover/blocs/discover_bloc.dart';
import 'package:calibre_web_companion/features/discover_details/bloc/discover_details_bloc.dart';
import 'package:calibre_web_companion/features/discover_details/data/datasources/discover_details_datasource.dart';
import 'package:calibre_web_companion/features/discover_details/data/repositories/discover_details_repository.dart';
import 'package:calibre_web_companion/features/login_settings/bloc/login_settings_event.dart';
import 'package:calibre_web_companion/features/me/bloc/me_bloc.dart';
import 'package:calibre_web_companion/features/me/data/datasources/me_datasource.dart';
import 'package:calibre_web_companion/features/me/data/repositories/me_repositorie.dart';
import 'package:calibre_web_companion/features/me/presentation/pages/me_page.dart';
import 'package:calibre_web_companion/features/shelf_details/bloc/shelf_details_bloc.dart';
import 'package:calibre_web_companion/features/shelf_details/data/datasources/shelf_details_datasource.dart';
import 'package:calibre_web_companion/features/shelf_details/data/repositories/shelf_details_repositorie.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/bloc/shelf_view_bloc.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/data/datasources/shelf_view_datasource.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/data/repositories/shelf_view_repositorie.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
// Core
import 'package:calibre_web_companion/core/services/api_service.dart';
// Features - Login
import 'package:calibre_web_companion/features/login/data/datasources/login_datasource.dart';
import 'package:calibre_web_companion/features/login/data/repositories/login_repository.dart';
import 'package:calibre_web_companion/features/login/bloc/login_bloc.dart';
import 'package:calibre_web_companion/features/login/presentation/pages/login_page.dart';
// Features - Login Settings
import 'package:calibre_web_companion/features/login_settings/data/datasources/login_settings_datasource.dart';
import 'package:calibre_web_companion/features/login_settings/data/repositories/login_settings_repository.dart';
import 'package:calibre_web_companion/features/login_settings/bloc/login_settings_bloc.dart';
final navigatorKey = GlobalKey<NavigatorState>();
final GetIt getIt = GetIt.instance;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final savedThemeMode = await AdaptiveTheme.getThemeMode();
// Setup dependency injection
await setupDependencies();
// Get the saved color key from SharedPreferences
final prefs = await SharedPreferences.getInstance();
final colorKey = prefs.getString('theme_color_key') ?? 'lightGreen';
final themeSourceIndex =
prefs.getInt('theme_source') ?? ThemeSource.custom.index;
// Load theme settings
final savedThemeMode = await AdaptiveTheme.getThemeMode();
// final prefs = await SharedPreferences.getInstance();
// final colorKey = prefs.getString('theme_color_key') ?? 'lightGreen';
// final themeSourceIndex = prefs.getInt('theme_source') ?? ThemeSource.custom.index;
runApp(
MultiProvider(
MultiBlocProvider(
providers: [
ChangeNotifierProvider(create: (_) => MainViewModel()),
ChangeNotifierProvider(create: (_) => LoginViewModel()),
ChangeNotifierProvider(create: (_) => HomepageViewModel()),
ChangeNotifierProvider(create: (_) => BooksViewModel()..refreshBooks()),
ChangeNotifierProvider(create: (_) => BookDetailsViewModel()),
ChangeNotifierProvider(create: (_) => MeViewModel()..getStats()),
ChangeNotifierProvider(create: (_) => BookListViewModel()),
ChangeNotifierProvider(
// BLoC Providers for new features
BlocProvider<LoginBloc>(create: (_) => getIt<LoginBloc>()),
BlocProvider<LoginSettingsBloc>(
create:
(_) => SettingsViewModel(
navigatorKey: navigatorKey,
initialColorKey: colorKey,
initialThemeSource: ThemeSource.values[themeSourceIndex],
)..loadSettings(),
(_) => getIt<LoginSettingsBloc>()..add(const LoadLoginSettings()),
),
ChangeNotifierProvider(create: (_) => DownloadServiceViewModel()),
ChangeNotifierProvider(create: (_) => ShelfViewModel()..loadShelfs()),
ChangeNotifierProvider(
create: (_) => LoginSettingsViewModel()..loadHeaders(),
BlocProvider<BookViewBloc>(
create: (_) => getIt<BookViewBloc>()..add(const LoadSettings()),
),
ChangeNotifierProvider(create: (_) => BookMetadataEditViewModel()),
ChangeNotifierProvider(create: (_) => BookRecommendationsViewModel()),
],
child: MyApp(savedThemeMode: savedThemeMode),
),
);
}
// Setup dependency injection
Future<void> setupDependencies() async {
final sharedPreferences = await SharedPreferences.getInstance();
// Register SharedPreferences
getIt.registerSingleton<SharedPreferences>(sharedPreferences);
// Register Services
getIt.registerLazySingleton<ApiService>(() => ApiService());
// Login Feature Dependencies
getIt.registerLazySingleton<LoginDataSource>(
() => LoginDataSource(apiService: getIt<ApiService>()),
);
getIt.registerLazySingleton<LoginRepository>(
() => LoginRepository(dataSource: getIt<LoginDataSource>()),
);
// Login Settings Feature Dependencies
getIt.registerLazySingleton<LoginSettingsDatasource>(
() => LoginSettingsDatasource(preferences: sharedPreferences),
);
getIt.registerLazySingleton<LoginSettingsRepository>(
() => LoginSettingsRepository(),
);
getIt.registerFactory<LoginSettingsBloc>(() => LoginSettingsBloc());
// Book List Feature Dependencies
getIt.registerLazySingleton<BookViewDatasource>(
() => BookViewDatasource(preferences: getIt<SharedPreferences>()),
);
getIt.registerLazySingleton<BookViewRepository>(
() => BookViewRepository(datasource: getIt<BookViewDatasource>()),
);
getIt.registerFactory<BookViewBloc>(
() => BookViewBloc(repository: getIt<BookViewRepository>()),
);
// Me Feature
// DataSources
getIt.registerLazySingleton<MeDataSource>(
() => MeDataSource(apiService: getIt<ApiService>()),
);
// Repositories
getIt.registerLazySingleton<MeRepository>(
() => MeRepository(dataSource: getIt<MeDataSource>()),
);
// BLoCs
getIt.registerFactory(() => MeBloc(repository: getIt<MeRepository>()));
// Discover Feature
getIt.registerFactory(() => DiscoverBloc());
// Discover Details Feature
getIt.registerLazySingleton<DiscoverDetailsDatasource>(
() => DiscoverDetailsDatasource(apiService: getIt<ApiService>()),
);
getIt.registerLazySingleton<DiscoverDetailsRepository>(
() => DiscoverDetailsRepository(
dataSource: getIt<DiscoverDetailsDatasource>(),
),
);
getIt.registerFactory<DiscoverDetailsBloc>(
() => DiscoverDetailsBloc(repository: getIt<DiscoverDetailsRepository>()),
);
// Shelf View Feature
getIt.registerLazySingleton<ShelfViewDataSource>(
() => ShelfViewDataSource(apiService: getIt<ApiService>()),
);
getIt.registerLazySingleton<ShelfViewRepository>(
() => ShelfViewRepository(dataSource: getIt<ShelfViewDataSource>()),
);
getIt.registerFactory<ShelfViewBloc>(
() => ShelfViewBloc(repository: getIt<ShelfViewRepository>()),
);
// Shelf Details Feature
getIt.registerLazySingleton<ShelfDetailsDataSource>(
() => ShelfDetailsDataSource(apiService: getIt<ApiService>()),
);
getIt.registerLazySingleton<ShelfDetailsRepository>(
() => ShelfDetailsRepository(dataSource: getIt<ShelfDetailsDataSource>()),
);
getIt.registerFactory<ShelfDetailsBloc>(
() => ShelfDetailsBloc(
repository: getIt<ShelfDetailsRepository>(),
shelfViewBloc: getIt<ShelfViewBloc>(),
),
);
}
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
class MyApp extends StatefulWidget {
@@ -76,98 +189,75 @@ class MyApp extends StatefulWidget {
}
class _MyAppState extends State<MyApp> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
}
// Check if the user is logged in by looking for a session cookie
Future<bool> _isLoggedIn() async {
final prefs = await SharedPreferences.getInstance();
final cookie = prefs.getString('calibre_web_session');
return cookie != null;
return await LoginRepository().isLoggedIn();
}
@override
Widget build(BuildContext context) {
return Consumer<SettingsViewModel>(
builder: (context, settingsViewModel, child) {
return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
// Get the base seed color from settings
final seedColor =
settingsViewModel.themeSource == ThemeSource.custom
? settingsViewModel.selectedColor
: Colors.lightGreen;
return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
// Get the base seed color from settings
final seedColor = Colors.lightGreen;
// Create the color schemes
final lightScheme =
settingsViewModel.themeSource == ThemeSource.system &&
lightDynamic != null
? lightDynamic
: ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.light,
);
// Create the color schemes
final lightScheme = ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.light,
);
final darkScheme =
settingsViewModel.themeSource == ThemeSource.system &&
darkDynamic != null
? darkDynamic
: ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
);
final darkScheme = ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
);
// Create the themes
final lightTheme = ThemeData(
useMaterial3: true,
colorScheme: lightScheme,
);
// Create the themes
final lightTheme = ThemeData(
useMaterial3: true,
colorScheme: lightScheme,
);
final darkTheme = ThemeData(
useMaterial3: true,
colorScheme: darkScheme,
);
final darkTheme = ThemeData(
useMaterial3: true,
colorScheme: darkScheme,
);
return MaterialApp(
title: 'Calibre-Web-Companion',
theme: lightTheme,
darkTheme: darkTheme,
themeMode: settingsViewModel.currentTheme,
navigatorKey: navigatorKey,
navigatorObservers: [routeObserver],
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
debugShowCheckedModeBanner: false,
localeResolutionCallback: (locale, supportedLocales) {
// If the locale of the device is supported, use it
if (locale != null) {
for (final supportedLocale in supportedLocales) {
if (supportedLocale.languageCode == locale.languageCode) {
return supportedLocale;
}
}
return MaterialApp(
title: 'Calibre-Web-Companion',
theme: lightTheme,
darkTheme: darkTheme,
navigatorKey: navigatorKey,
navigatorObservers: [routeObserver],
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
debugShowCheckedModeBanner: false,
localeResolutionCallback: (locale, supportedLocales) {
// If the locale of the device is supported, use it
if (locale != null) {
for (final supportedLocale in supportedLocales) {
if (supportedLocale.languageCode == locale.languageCode) {
return supportedLocale;
}
// else use the default one
return const Locale('en');
},
home: FutureBuilder<bool>(
future: _isLoggedIn(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final isLoggedIn = snapshot.data ?? false;
return isLoggedIn ? const HomepageView() : const LoginView();
},
),
);
}
}
// else use the default one
return const Locale('en');
},
home: FutureBuilder<bool>(
future: _isLoggedIn(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final isLoggedIn = snapshot.data ?? false;
return isLoggedIn ? MePage() : const LoginPage();
},
),
);
},
);
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
class LongButton extends StatelessWidget {
final String text;
final IconData icon;
final VoidCallback onPressed;
final bool isLoading;
const LongButton({
super.key,
required this.text,
required this.icon,
required this.onPressed,
this.isLoading = false,
});
@override
Widget build(BuildContext context) {
BorderRadius borderRadius = BorderRadius.circular(8.0);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 3,
shape: RoundedRectangleBorder(borderRadius: borderRadius),
child: Material(
color: Theme.of(context).cardColor,
borderRadius: borderRadius,
child: InkWell(
borderRadius: borderRadius,
onTap: isLoading ? null : onPressed,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
icon,
size: 28,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 16),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.titleMedium,
),
),
Icon(
Icons.arrow_forward_ios_rounded,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
),
),
);
}
}
@@ -1,245 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:logger/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum AuthSystem {
none,
authelia,
cloudflareZeroTrust,
swag,
traefik,
nginxProxy,
custom,
}
class LoginSettingsViewModel extends ChangeNotifier {
Logger logger = Logger();
List<Map<String, String>> _customHeaders = [];
bool _isLoading = true;
String _basePath = '';
AuthSystem _selectedAuthSystem = AuthSystem.none;
// Getter
List<Map<String, String>> get customHeaders => _customHeaders;
bool get isLoading => _isLoading;
String get basePath => _basePath;
AuthSystem get selectedAuthSystem => _selectedAuthSystem;
Map<AuthSystem, String> get authSystemNames => {
AuthSystem.none: 'None',
AuthSystem.authelia: 'Authelia',
AuthSystem.cloudflareZeroTrust: 'Cloudflare Zero Trust',
AuthSystem.swag: 'SWAG',
AuthSystem.traefik: 'Traefik',
AuthSystem.nginxProxy: 'Nginx Proxy Manager',
AuthSystem.custom: 'Custom',
};
/// Get string from SharedPreferences
Future<String> getString(String string) async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(string) ?? '[]';
}
/// Set string in SharedPreferences
Future<void> setString(String key, String value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(key, value);
}
/// Load all settings from SharedPreferences
Future<void> loadSettings() async {
_isLoading = true;
notifyListeners();
try {
await loadHeaders();
final prefs = await SharedPreferences.getInstance();
_basePath = prefs.getString('base_path') ?? '';
final authSystemString = prefs.getString('auth_system') ?? 'none';
try {
_selectedAuthSystem = AuthSystem.values.firstWhere(
(e) => e.toString().split('.').last == authSystemString,
orElse: () => AuthSystem.none,
);
} catch (e) {
_selectedAuthSystem = AuthSystem.none;
}
logger.i('Loaded auth system: $_selectedAuthSystem');
logger.i('Loaded base path: $_basePath');
} catch (e) {
logger.e('Error loading settings: $e');
} finally {
_isLoading = false;
notifyListeners();
}
}
/// Load headers from SharedPreferences
Future<void> loadHeaders() async {
try {
final headersJson = await getString('custom_login_headers');
final List<dynamic> decodedList = jsonDecode(headersJson);
_customHeaders =
decodedList
.map((item) => Map<String, String>.from(item as Map))
.toList();
logger.i('Loaded headers: $_customHeaders');
} catch (e) {
_customHeaders = [];
logger.e('Error loading headers: $e');
}
}
/// Save all settings to SharedPreferences
Future<void> saveAllSettings() async {
try {
await _saveHeaders();
final prefs = await SharedPreferences.getInstance();
await prefs.setString('base_path', _basePath);
await prefs.setString(
'auth_system',
_selectedAuthSystem.toString().split('.').last,
);
logger.i('Saved all settings');
} catch (e) {
logger.e('Error saving settings: $e');
}
}
/// Save headers to SharedPreferences
Future<void> _saveHeaders() async {
try {
final headersJson = jsonEncode(_customHeaders);
await setString("custom_login_headers", headersJson);
logger.i('Saved headers: $_customHeaders');
} catch (e) {
logger.e('Error saving headers: $e');
} finally {
notifyListeners();
}
}
/// Update base path
void setBasePath(String newBasePath) {
if (newBasePath.isNotEmpty) {
if (newBasePath.startsWith('/')) {
newBasePath = newBasePath.substring(1);
}
if (newBasePath.endsWith('/')) {
newBasePath = newBasePath.substring(0, newBasePath.length - 1);
}
}
_basePath = newBasePath;
notifyListeners();
}
/// Set authentication system and apply predefined headers
void setAuthSystem(AuthSystem system) {
_selectedAuthSystem = system;
switch (system) {
case AuthSystem.none:
_customHeaders = [];
break;
case AuthSystem.authelia:
_customHeaders = [
{'Remote-User': '\${USERNAME}'},
{'Remote-Name': '\${USERNAME}'},
{'Remote-Email': '\${USERNAME}@example.com'},
{'Remote-Groups': 'calibre_users'},
];
break;
case AuthSystem.cloudflareZeroTrust:
_customHeaders = [
{'CF-Access-Client-Id': ''},
{'CF-Access-Client-Secret': ''},
{'CF-Access-Jwt-Assertion': ''},
];
break;
case AuthSystem.swag:
_customHeaders = [
{'X-Forwarded-Host': 'true'},
{'X-Forwarded-Proto': 'https'},
{'X-Forwarded-For': ''},
];
break;
case AuthSystem.traefik:
_customHeaders = [
{'X-Forwarded-User': '\${USERNAME}'},
{'X-Forwarded-Proto': 'https'},
{'X-Forwarded-Method': 'GET'},
];
break;
case AuthSystem.nginxProxy:
_customHeaders = [
{'X-Forwarded-User': '\${USERNAME}'},
{'X-Forwarded-Proto': 'https'},
{'X-Real-IP': ''},
];
break;
case AuthSystem.custom:
if (_customHeaders.isEmpty) {
_customHeaders = [
{'': ''},
];
}
break;
}
_saveHeaders();
}
/// Add new header
void addHeader() {
_customHeaders.add({'': ''});
_saveHeaders();
}
/// Delete header
void deleteHeader(int index) {
_customHeaders.removeAt(index);
_saveHeaders();
}
/// Update header key
///
/// Parameters:
///
/// - `index`: Index of the header to update
/// - `newKey`: New key to set
void updateHeaderKey(int index, String newKey) {
final value = _customHeaders[index].values.first;
_customHeaders[index] = {newKey: value};
_saveHeaders();
}
/// Update header value
///
/// Parameters:
///
/// - `index`: Index of the header to update
/// - `newValue`: New value to set
void updateHeaderValue(int index, String newValue) {
final key = _customHeaders[index].keys.first;
_customHeaders[index] = {key: newValue};
_saveHeaders();
}
}

Some files were not shown because too many files have changed in this diff Show More