begon to merge to bloc
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Vendored
+25
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
class AuthException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
AuthException(this.message, {this.statusCode});
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
// }
|
||||
}
|
||||
@@ -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 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+29
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user