Added ability to change font size + added new grid view and list view also to discover tabs + update library search on homescreen

This commit is contained in:
Daniel
2026-06-06 23:48:47 +02:00
parent 7844be0e69
commit babb8685ac
14 changed files with 378 additions and 108 deletions
@@ -59,9 +59,6 @@ class BookViewBloc extends Bloc<BookViewEvent, BookViewState> {
isLoading: false,
books: books,
offset: state.limit,
// OPDS returns the whole feed at once; everything else paginates until
// an empty page. Don't gate on the parsed count (a dropped/short page
// would falsely stop pagination — e.g. the authors sort).
hasMoreBooks: isOpds ? false : books.isNotEmpty,
isOpds: isOpds,
),
@@ -19,6 +19,7 @@ import 'package:calibre_web_companion/features/book_view/presentation/widgets/se
import 'package:calibre_web_companion/features/scan_book/presentation/pages/scan_book_page.dart';
import 'package:calibre_web_companion/features/scan_book/presentation/scan_flow.dart';
import 'package:calibre_web_companion/shared/widgets/app_options_sheet.dart';
import 'package:calibre_web_companion/shared/widgets/book_view_mode_selector.dart';
class BookViewPage extends StatefulWidget {
const BookViewPage({super.key});
@@ -76,13 +77,32 @@ class _BookViewPageState extends State<BookViewPage> {
}
},
builder: (context, state) {
final bool isSearching = (state.searchQuery ?? '').isNotEmpty;
return Scaffold(
appBar: AppBar(
title: Text(localizations.books),
leading:
isSearching
? IconButton(
icon: const Icon(Icons.arrow_back_rounded),
tooltip: localizations.clearSearch,
onPressed: () => _clearSearch(context),
)
: null,
title:
isSearching
? Text('${localizations.search}: ${state.searchQuery}')
: Text(localizations.books),
actions: [
_buildColumnSelector(context, state, localizations),
const BookViewModeSelector(),
if (!state.isOpds) _buildSortOptions(context, localizations),
_buildSearchButton(context, localizations),
if (isSearching)
IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: localizations.clearSearch,
onPressed: () => _clearSearch(context),
)
else
_buildSearchButton(context, localizations),
],
),
body: _buildBody(context, state, localizations),
@@ -259,71 +279,6 @@ class _BookViewPageState extends State<BookViewPage> {
);
}
Widget _buildColumnSelector(
BuildContext context,
BookViewState state,
AppLocalizations localizations,
) {
return PopupMenuButton<dynamic>(
icon: Icon(
state.isListView ? Icons.view_list_rounded : Icons.grid_view_rounded,
),
tooltip: localizations.columnsCount,
onSelected: (dynamic value) {
if (value == 'list') {
context.read<BookViewBloc>().add(const SetViewMode(true));
} else if (value is int) {
context.read<BookViewBloc>().add(ChangeColumnCount(value));
}
},
itemBuilder:
(context) => [
PopupMenuItem(
value: 'list',
child: Row(
children: [
Icon(
Icons.view_list,
color:
state.isListView
? Theme.of(context).colorScheme.primary
: null,
),
const SizedBox(width: 8),
Text(localizations.listView),
],
),
),
const PopupMenuDivider(),
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.isListView && state.columnCount == i
? Theme.of(context).colorScheme.primary
: null,
),
const SizedBox(width: 8),
Text('$i ${localizations.columns}'),
],
),
),
],
);
}
Widget _buildSortOptions(
BuildContext context,
AppLocalizations localizations,
@@ -433,6 +388,10 @@ class _BookViewPageState extends State<BookViewPage> {
);
}
void _clearSearch(BuildContext context) {
context.read<BookViewBloc>().add(const SearchBooks(''));
}
Future<void> _pickAndUploadBook(
BuildContext context,
AppLocalizations localizations,
@@ -9,12 +9,16 @@ import 'package:calibre_web_companion/l10n/app_localizations.dart';
import 'package:calibre_web_companion/features/book_details/presentation/pages/book_details_page.dart';
import 'package:calibre_web_companion/shared/widgets/book_card_skeleton_widget.dart';
import 'package:calibre_web_companion/shared/widgets/book_card_widget.dart';
import 'package:calibre_web_companion/shared/widgets/book_cover_widget.dart';
import 'package:calibre_web_companion/shared/widgets/book_view_mode_selector.dart';
import 'package:calibre_web_companion/features/book_view/bloc/book_view_bloc.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/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/data/models/discover_details_model.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';
@@ -71,6 +75,8 @@ class DiscoverDetailsPage extends StatelessWidget {
return Scaffold(
appBar: AppBar(
title: _buildAppBarTitle(context, title, categoryType),
actions:
state.isShowingBooks ? const [BookViewModeSelector()] : null,
),
body: _buildBody(context, state, localizations),
);
@@ -216,11 +222,23 @@ class DiscoverDetailsPage extends StatelessWidget {
Widget _buildBookGrid(BuildContext context, DiscoverFeedModel feed) {
return BlocBuilder<DiscoverDetailsBloc, DiscoverDetailsState>(
builder: (context, state) {
final viewState = context.watch<BookViewBloc>().state;
if (viewState.isListView) {
return ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: feed.books.length,
itemBuilder:
(context, index) =>
_buildBookListTile(context, feed.books[index], state),
);
}
return GridView.builder(
padding: const EdgeInsets.all(16.0),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.7,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: viewState.columnCount,
childAspectRatio: viewState.columnCount <= 2 ? 0.7 : 0.9,
crossAxisSpacing: 16.0,
mainAxisSpacing: 16.0,
),
@@ -233,39 +251,7 @@ class DiscoverDetailsPage extends StatelessWidget {
title: book.title,
authors: book.authors,
isLoading: state.loadingBookId == book.id,
onTap: () async {
final int parsedId = int.tryParse(book.id)!;
Navigator.of(context).push(
MaterialPageRoute(
builder:
(context) => BookDetailsPage(
bookViewModel: BookDetailsModel(
id: parsedId,
uuid: book.uuid,
title: book.title,
authors: book.authors,
cover: book.coverUrl ?? '',
coverUrl: book.coverUrl,
comments: book.summary ?? '',
data:
book.summary ??
'', // <--- DAS HIER HAT GEFEHLT!
tags: book.tags,
hasCover: book.coverUrl != null,
path: '',
pubdate: '',
series: '',
seriesIndex: 0,
rating: 0,
languages: '',
publishers: '',
),
bookUuid: book.uuid,
),
),
);
},
onTap: () => _openBook(context, book),
);
},
);
@@ -273,6 +259,103 @@ class DiscoverDetailsPage extends StatelessWidget {
);
}
Widget _buildBookListTile(
BuildContext context,
DiscoverDetailsModel book,
DiscoverDetailsState state,
) {
final parsedId = int.tryParse(book.id) ?? 0;
return Card(
margin: const EdgeInsets.only(bottom: 12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => _openBook(context, book),
child: SizedBox(
height: 140,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 2 / 3,
child: BookCoverWidget(
bookId: parsedId,
coverUrl: book.coverUrl,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book.title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
book.authors,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
if (state.loadingBookId == book.id)
const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
),
),
),
);
}
void _openBook(BuildContext context, DiscoverDetailsModel book) {
final int parsedId = int.tryParse(book.id)!;
Navigator.of(context).push(
MaterialPageRoute(
builder:
(context) => BookDetailsPage(
bookViewModel: BookDetailsModel(
id: parsedId,
uuid: book.uuid,
title: book.title,
authors: book.authors,
cover: book.coverUrl ?? '',
coverUrl: book.coverUrl,
comments: book.summary ?? '',
data: book.summary ?? '',
tags: book.tags,
hasCover: book.coverUrl != null,
path: '',
pubdate: '',
series: '',
seriesIndex: 0,
rating: 0,
languages: '',
publishers: '',
),
bookUuid: book.uuid,
),
),
);
}
Widget _buildCategoryList(BuildContext context, CategoryFeed feed) {
return ListView.builder(
itemCount: feed.categories.length,
@@ -39,6 +39,7 @@ class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
on<ResetConnectionTestStatus>(_onResetConnectionTestStatus);
on<SetShowSendToEReaderButton>(_onSetShowSendToEReaderButton);
on<SetEInkMode>(_onSetEInkMode);
on<SetTextScale>(_onSetTextScale);
on<SetBookActionsOrder>(_onSetBookActionsOrder);
on<SetBookActionEnabled>(_onSetBookActionEnabled);
on<ResetBookActionsCustomization>(_onResetBookActionsCustomization);
@@ -92,6 +93,7 @@ class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
webDavPassword: settings.webDavPassword,
isWebDavSyncEnabled: settings.isWebDavSyncEnabled,
isEInkMode: settings.isEInkMode,
textScale: settings.textScale,
bookActionsOrder: settings.bookActionsOrder,
enabledBookActions: settings.enabledBookActions,
bookDetailsSectionsOrder: settings.bookDetailsSectionsOrder,
@@ -527,6 +529,23 @@ class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
}
}
Future<void> _onSetTextScale(
SetTextScale event,
Emitter<SettingsState> emit,
) async {
try {
await repository.setTextScale(event.scale);
emit(state.copyWith(textScale: event.scale));
} catch (e) {
emit(
state.copyWith(
status: SettingsStatus.error,
errorMessage: e.toString(),
),
);
}
}
Future<void> _onSetEInkMode(
SetEInkMode event,
Emitter<SettingsState> emit,
@@ -220,6 +220,15 @@ class SetEInkMode extends SettingsEvent {
List<Object?> get props => [enabled];
}
class SetTextScale extends SettingsEvent {
final double scale;
const SetTextScale(this.scale);
@override
List<Object?> get props => [scale];
}
class SetBookActionsOrder extends SettingsEvent {
final List<String> actionKeys;
@@ -37,6 +37,7 @@ class SettingsState extends Equatable {
final String webDavUsername;
final String webDavPassword;
final bool isEInkMode;
final double textScale;
final List<String> bookActionsOrder;
final List<String> enabledBookActions;
final List<String> bookDetailsSectionsOrder;
@@ -78,6 +79,7 @@ class SettingsState extends Equatable {
this.webDavUsername = '',
this.webDavPassword = '',
this.isEInkMode = false,
this.textScale = 1.0,
this.bookActionsOrder = const [],
this.enabledBookActions = const [],
this.bookDetailsSectionsOrder = const [],
@@ -122,6 +124,7 @@ class SettingsState extends Equatable {
String? webDavUsername,
String? webDavPassword,
bool? isEInkMode,
double? textScale,
List<String>? bookActionsOrder,
List<String>? enabledBookActions,
List<String>? bookDetailsSectionsOrder,
@@ -166,6 +169,7 @@ class SettingsState extends Equatable {
webDavUsername: webDavUsername ?? this.webDavUsername,
webDavPassword: webDavPassword ?? this.webDavPassword,
isEInkMode: isEInkMode ?? this.isEInkMode,
textScale: textScale ?? this.textScale,
bookActionsOrder: bookActionsOrder ?? this.bookActionsOrder,
enabledBookActions: enabledBookActions ?? this.enabledBookActions,
bookDetailsSectionsOrder:
@@ -213,6 +217,7 @@ class SettingsState extends Equatable {
webDavUsername,
webDavPassword,
isEInkMode,
textScale,
bookActionsOrder,
enabledBookActions,
bookDetailsSectionsOrder,
@@ -58,6 +58,7 @@ class SettingsLocalDataSource {
'epub_scroll_direction':
sharedPreferences.getString('epub_scroll_direction') ?? 'vertical',
'is_eink_mode': sharedPreferences.getBool('is_eink_mode') ?? false,
'text_scale': sharedPreferences.getDouble('text_scale') ?? 1.0,
'book_actions_order':
sharedPreferences.getStringList('book_actions_order') ??
BookDetailsActionConfig.defaultOrder,
@@ -328,6 +329,15 @@ class SettingsLocalDataSource {
}
}
Future<void> saveTextScale(double scale) async {
try {
await sharedPreferences.setDouble('text_scale', scale);
} catch (e) {
logger.e('Error saving text scale: $e');
throw Exception('Failed to save text scale: $e');
}
}
Future<void> saveBookActionsOrder(List<String> actionKeys) async {
try {
await sharedPreferences.setStringList('book_actions_order', actionKeys);
@@ -28,6 +28,7 @@ class SettingsModel extends Equatable {
final String webDavPassword;
final bool isWebDavSyncEnabled;
final bool isEInkMode;
final double textScale;
final List<String> bookActionsOrder;
final List<String> enabledBookActions;
final List<String> bookDetailsSectionsOrder;
@@ -60,6 +61,7 @@ class SettingsModel extends Equatable {
required this.webDavPassword,
required this.isWebDavSyncEnabled,
required this.isEInkMode,
required this.textScale,
required this.bookActionsOrder,
required this.enabledBookActions,
required this.bookDetailsSectionsOrder,
@@ -95,6 +97,7 @@ class SettingsModel extends Equatable {
webDavPassword: json['webdav_password'] ?? '',
isWebDavSyncEnabled: json['webdav_enabled'] ?? false,
isEInkMode: json['is_eink_mode'] ?? false,
textScale: (json['text_scale'] as num?)?.toDouble() ?? 1.0,
bookActionsOrder: BookDetailsActionConfig.normalizeOrder(
List<String>.from(
json['book_actions_order'] ?? BookDetailsActionConfig.defaultOrder,
@@ -180,6 +183,7 @@ class SettingsModel extends Equatable {
webDavPassword,
isWebDavSyncEnabled,
isEInkMode,
textScale,
bookActionsOrder,
enabledBookActions,
bookDetailsSectionsOrder,
@@ -243,6 +243,14 @@ class SettingsRepository {
}
}
Future<void> setTextScale(double scale) async {
try {
await dataSource.saveTextScale(scale);
} catch (e) {
rethrow;
}
}
Future<void> setBookActionsOrder(List<String> actionKeys) async {
try {
await dataSource.saveBookActionsOrder(actionKeys);
@@ -300,6 +300,7 @@ class _SettingsPageState extends State<SettingsPage> {
_buildSectionTitle(context, localizations.appearance),
const ThemeSelectorWidget(),
_buildEInkModeToggle(context, state, localizations),
_buildTextScaleSelector(context, state, localizations),
const SizedBox(height: 24),
_buildSectionTitle(context, localizations.language),
_buildLanguageSelector(context, state, localizations),
@@ -485,6 +486,83 @@ class _SettingsPageState extends State<SettingsPage> {
);
}
Widget _buildTextScaleSelector(
BuildContext context,
SettingsState state,
AppLocalizations localizations,
) {
double current = state.textScale;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 3,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
child: StatefulBuilder(
builder: (context, setLocal) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.format_size_rounded,
size: 28,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizations.fontSize,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
localizations.fontSizeDescription,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
Text(
'${(current * 100).round()}%',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
Slider(
value: current.clamp(0.8, 1.6),
min: 0.8,
max: 1.6,
divisions: 8,
label: '${(current * 100).round()}%',
onChanged: (value) => setLocal(() => current = value),
onChangeEnd:
(value) => context.read<SettingsBloc>().add(
SetTextScale(value),
),
),
],
),
);
},
),
);
}
Widget _buildEInkModeToggle(
BuildContext context,
SettingsState state,
+3
View File
@@ -15,6 +15,7 @@
"me": "Ich",
"discoverBooks": "Bücher entdecken",
"search": "Suchen",
"clearSearch": "Suche zurücksetzen",
"searchBook": "Buch suchen",
"enterTitleAuthorOrTags": "Titel, Autor oder Tags eingeben ...",
"showReadBooks": "Gelesene Bücher",
@@ -103,6 +104,8 @@
"error": "Fehler",
"errorLoadingBooks": "Fehler beim Laden der Bücher",
"eInkMode": "E-Ink Modus",
"fontSize": "Schriftgröße",
"fontSizeDescription": "Textgröße in der gesamten App skalieren",
"eInkModeDescription": "Deaktiviert Animationen für E-Ink Geräte",
"deleteBook": "Buch löschen",
"deleteBookConfirmation": "Bist du sicher, dass du dieses Buch löschen möchtest?",
+3
View File
@@ -15,6 +15,7 @@
"me": "Me",
"discoverBooks": "Discover books",
"search": "Search",
"clearSearch": "Clear search",
"searchBook": "Search book",
"enterTitleAuthorOrTags": "Enter title, author or tags ...",
"showReadBooks": "Show read books",
@@ -103,6 +104,8 @@
"error": "Error",
"errorLoadingBooks": "Error loading books",
"eInkMode": "E-Ink Mode",
"fontSize": "Font size",
"fontSizeDescription": "Scale the text size across the whole app",
"eInkModeDescription": "Disables animations for E-Ink devices",
"deleteBook": "Delete book",
"deleteBookConfirmation": "Are you sure you want to delete this book?",
+11 -1
View File
@@ -166,7 +166,8 @@ class _MyAppState extends State<MyApp> {
previous.themeSource != current.themeSource ||
previous.selectedColorKey != current.selectedColorKey ||
previous.languageCode != current.languageCode ||
previous.isEInkMode != current.isEInkMode,
previous.isEInkMode != current.isEInkMode ||
previous.textScale != current.textScale,
builder: (context, settingsState) {
return DynamicColorBuilder(
@@ -226,6 +227,15 @@ class _MyAppState extends State<MyApp> {
themeMode: settingsState.themeMode,
navigatorKey: navigatorKey,
navigatorObservers: [routeObserver],
builder: (context, child) {
final mediaQuery = MediaQuery.of(context);
return MediaQuery(
data: mediaQuery.copyWith(
textScaler: TextScaler.linear(settingsState.textScale),
),
child: child ?? const SizedBox.shrink(),
);
},
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: Locale(settingsState.languageCode ?? 'en'),
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:calibre_web_companion/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';
class BookViewModeSelector extends StatelessWidget {
const BookViewModeSelector({super.key});
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
return BlocBuilder<BookViewBloc, BookViewState>(
buildWhen:
(previous, current) =>
previous.isListView != current.isListView ||
previous.columnCount != current.columnCount,
builder: (context, state) {
final scheme = Theme.of(context).colorScheme;
return PopupMenuButton<dynamic>(
icon: Icon(
state.isListView
? Icons.view_list_rounded
: Icons.grid_view_rounded,
),
tooltip: localizations.columnsCount,
onSelected: (dynamic value) {
if (value == 'list') {
context.read<BookViewBloc>().add(const SetViewMode(true));
} else if (value is int) {
context.read<BookViewBloc>().add(ChangeColumnCount(value));
}
},
itemBuilder:
(context) => [
PopupMenuItem(
value: 'list',
child: Row(
children: [
Icon(
Icons.view_list,
color: state.isListView ? scheme.primary : null,
),
const SizedBox(width: 8),
Text(localizations.listView),
],
),
),
const PopupMenuDivider(),
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.isListView && state.columnCount == i
? scheme.primary
: null,
),
const SizedBox(width: 8),
Text('$i ${localizations.columns}'),
],
),
),
],
);
},
);
}
}