feature: added two more widget types (#172)

This commit is contained in:
Daniel
2026-07-13 16:13:53 +02:00
parent c94f842cd4
commit 10068bde93
27 changed files with 1577 additions and 88 deletions
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter/widgets.dart';
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/widget_service.dart';
import 'package:calibre_web_companion/features/offline/data/repositories/offline_library_repository.dart';
@pragma('vm:entry-point')
Future<void> widgetBackgroundCallback(Uri? uri) async {
if (uri == null || uri.scheme != 'calibrewebcompanion') return;
if (!uri.pathSegments.contains('refresh')) return;
WidgetsFlutterBinding.ensureInitialized();
final logger = Logger();
final prefs = await SharedPreferences.getInstance();
await ApiService().initialize();
final widgetService = WidgetService(
prefs: prefs,
logger: logger,
offlineRepository: OfflineLibraryRepository(prefs: prefs, logger: logger),
);
await widgetService.refreshShelf();
}
+171
View File
@@ -10,6 +10,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:calibre_web_companion/core/services/api_service.dart';
import 'package:calibre_web_companion/core/services/image_cache_manager.dart';
import 'package:calibre_web_companion/core/services/widget_background.dart';
import 'package:calibre_web_companion/core/services/widget_shelf_loader.dart';
import 'package:calibre_web_companion/features/offline/data/repositories/offline_library_repository.dart';
import 'package:calibre_web_companion/features/settings/data/models/predefined_colors.dart';
@@ -57,9 +59,16 @@ class WidgetService {
static const String _currentBookProvider = 'CurrentBookWidgetProvider';
static const String _statsProvider = 'LibraryStatsWidgetProvider';
static const String _shelfProvider = 'ShelfWidgetProvider';
static const String _quickActionsProvider = 'QuickActionsWidgetProvider';
static const String kTapTargetKey = 'widget_tap_target';
static const String kShelfSourceKey = 'widget_shelf_source';
static const String kShelfIdKey = 'widget_shelf_id';
static const String kShelfLabelKey = 'widget_shelf_label';
static const String _kCurrentBookKey = 'widget_current_book';
static const String _kShelfBooksKey = 'widget_shelf_books';
static const int shelfMaxBooks = 40;
bool get _supported => Platform.isAndroid;
@@ -70,6 +79,39 @@ class WidgetService {
await prefs.setString(kTapTargetKey, target.key);
}
WidgetShelfSource get shelfSource =>
WidgetShelfSourceX.fromKey(prefs.getString(kShelfSourceKey));
String get shelfId => prefs.getString(kShelfIdKey) ?? '';
String get shelfLabel => prefs.getString(kShelfLabelKey) ?? '';
Future<void> setShelfConfig({
required WidgetShelfSource source,
String id = '',
String label = '',
}) async {
await prefs.setString(kShelfSourceKey, source.key);
await prefs.setString(kShelfIdKey, id);
await prefs.setString(kShelfLabelKey, label);
await refreshShelf();
}
List<WidgetShelfBook> get shelfBooks {
final raw = prefs.getString(_kShelfBooksKey);
if (raw == null || raw.isEmpty) return const [];
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return const [];
return decoded
.whereType<Map<String, dynamic>>()
.map(WidgetShelfBook.fromJson)
.toList();
} catch (_) {
return const [];
}
}
Map<String, dynamic>? get currentBookRaw {
final raw = prefs.getString(_kCurrentBookKey);
if (raw == null || raw.isEmpty) return null;
@@ -206,6 +248,74 @@ class WidgetService {
}
}
Future<void> refreshShelf() async {
if (!_supported) return;
final source = shelfSource;
final id = shelfId;
List<WidgetShelfBook> books = const [];
try {
final loader = WidgetShelfLoader(
prefs: prefs,
logger: logger,
offlineRepository: offlineRepository,
);
books = await loader.load(
source: source,
shelfId: id,
limit: shelfMaxBooks,
);
} catch (e) {
logger.w('Failed to load books for shelf widget: $e');
return;
}
final resolved = await _resolveCovers(books);
await prefs.setString(
_kShelfBooksKey,
jsonEncode(resolved.map((b) => b.toJson()).toList()),
);
await _pruneWidgetCovers(resolved);
try {
await HomeWidget.saveWidgetData<String>('sh_title', shelfLabel);
await HomeWidget.saveWidgetData<String>(
'sh_json',
jsonEncode(
resolved
.map(
(b) => {
'uuid': b.uuid,
'title': b.title,
'authors': b.authors,
'cover': b.coverPath,
},
)
.toList(),
),
);
await HomeWidget.updateWidget(androidName: _shelfProvider);
} catch (e) {
logger.w('Failed to push shelf widget: $e');
}
}
Future<void> pushQuickActions() async {
if (!_supported) return;
try {
final downloaderEnabled = prefs.getBool('downloader_enabled') ?? false;
await HomeWidget.saveWidgetData<String>(
'qa_downloads',
downloaderEnabled ? '1' : '0',
);
await HomeWidget.updateWidget(androidName: _quickActionsProvider);
} catch (e) {
logger.w('Failed to push quick actions widget: $e');
}
}
Future<void> pushThemeColors() async {
final seed = _resolveSeedColor();
final light = ColorScheme.fromSeed(
@@ -243,6 +353,8 @@ class WidgetService {
}
await HomeWidget.updateWidget(androidName: _currentBookProvider);
await HomeWidget.updateWidget(androidName: _statsProvider);
await HomeWidget.updateWidget(androidName: _shelfProvider);
await HomeWidget.updateWidget(androidName: _quickActionsProvider);
} catch (e) {
logger.w('Failed to push widget theme colors: $e');
}
@@ -256,6 +368,15 @@ class WidgetService {
String _hex(Color color) =>
'#${color.toARGB32().toRadixString(16).padLeft(8, '0')}';
Future<void> registerBackgroundCallback() async {
if (!_supported) return;
try {
await HomeWidget.registerInteractivityCallback(widgetBackgroundCallback);
} catch (e) {
logger.w('Failed to register widget background callback: $e');
}
}
Stream<Uri?> get widgetClicks => HomeWidget.widgetClicked;
Future<Uri?> initialWidgetLaunch() =>
@@ -315,6 +436,56 @@ class WidgetService {
return '$baseUrl/opds/cover/$id';
}
Future<List<WidgetShelfBook>> _resolveCovers(
List<WidgetShelfBook> books,
) async {
const batchSize = 6;
final resolved = <WidgetShelfBook>[];
for (var start = 0; start < books.length; start += batchSize) {
final batch = books.skip(start).take(batchSize);
resolved.addAll(
await Future.wait(
batch.map((book) async {
if (book.coverPath.isNotEmpty &&
await File(book.coverPath).exists()) {
return book;
}
final path = await _materializeCover(
book.uuid,
book.id,
book.coverUrl,
);
return book.copyWith(coverPath: path ?? '');
}),
),
);
}
return resolved;
}
Future<void> _pruneWidgetCovers(List<WidgetShelfBook> keep) async {
try {
final supportDir = await getApplicationSupportDirectory();
final widgetDir = Directory(p.join(supportDir.path, 'widget'));
if (!await widgetDir.exists()) return;
final keepPaths = <String>{
prefs.getString('widget_current_cover_path') ?? '',
for (final book in keep) book.coverPath,
}..removeWhere((path) => path.isEmpty);
await for (final entity in widgetDir.list()) {
if (entity is File && !keepPaths.contains(entity.path)) {
await entity.delete();
}
}
} catch (e) {
logger.w('Failed to prune widget covers: $e');
}
}
Future<String?> _copyToWidgetDir(String uuid, File source) async {
try {
final supportDir = await getApplicationSupportDirectory();
+216
View File
@@ -0,0 +1,216 @@
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/features/book_view/data/datasources/book_view_remote_datasource.dart';
import 'package:calibre_web_companion/features/offline/data/repositories/offline_library_repository.dart';
import 'package:calibre_web_companion/features/shelf_details/data/datasources/shelf_details_remote_datasource.dart';
enum WidgetShelfSource { bookList, shelf, magicShelf, offline }
extension WidgetShelfSourceX on WidgetShelfSource {
String get key {
switch (this) {
case WidgetShelfSource.bookList:
return 'book_list';
case WidgetShelfSource.shelf:
return 'shelf';
case WidgetShelfSource.magicShelf:
return 'magic_shelf';
case WidgetShelfSource.offline:
return 'offline';
}
}
bool get needsShelfId =>
this == WidgetShelfSource.shelf || this == WidgetShelfSource.magicShelf;
static WidgetShelfSource fromKey(String? key) {
switch (key) {
case 'shelf':
return WidgetShelfSource.shelf;
case 'magic_shelf':
return WidgetShelfSource.magicShelf;
case 'offline':
return WidgetShelfSource.offline;
case 'book_list':
default:
return WidgetShelfSource.bookList;
}
}
}
class WidgetShelfBook {
final String uuid;
final int id;
final String title;
final String authors;
final String coverUrl;
final String coverPath;
final String format;
const WidgetShelfBook({
required this.uuid,
required this.id,
required this.title,
required this.authors,
this.coverUrl = '',
this.coverPath = '',
this.format = 'epub',
});
WidgetShelfBook copyWith({String? coverPath}) => WidgetShelfBook(
uuid: uuid,
id: id,
title: title,
authors: authors,
coverUrl: coverUrl,
coverPath: coverPath ?? this.coverPath,
format: format,
);
Map<String, dynamic> toJson() => {
'uuid': uuid,
'id': id,
'title': title,
'authors': authors,
'coverUrl': coverUrl,
'coverPath': coverPath,
'format': format,
};
factory WidgetShelfBook.fromJson(Map<String, dynamic> json) =>
WidgetShelfBook(
uuid: json['uuid']?.toString() ?? '',
id: (json['id'] as num?)?.toInt() ?? 0,
title: json['title']?.toString() ?? '',
authors: json['authors']?.toString() ?? '',
coverUrl: json['coverUrl']?.toString() ?? '',
coverPath: json['coverPath']?.toString() ?? '',
format: json['format']?.toString() ?? 'epub',
);
}
class WidgetShelfLoader {
final SharedPreferences prefs;
final Logger logger;
final OfflineLibraryRepository offlineRepository;
WidgetShelfLoader({
required this.prefs,
required this.logger,
required this.offlineRepository,
});
Future<List<WidgetShelfBook>> load({
required WidgetShelfSource source,
required String shelfId,
required int limit,
}) async {
switch (source) {
case WidgetShelfSource.offline:
return _loadOffline(limit);
case WidgetShelfSource.bookList:
return _loadBookList(limit);
case WidgetShelfSource.shelf:
case WidgetShelfSource.magicShelf:
if (shelfId.isEmpty) return const [];
return _loadShelf(
shelfId,
limit,
isMagic: source == WidgetShelfSource.magicShelf,
);
}
}
List<WidgetShelfBook> _loadOffline(int limit) {
final books =
offlineRepository.getAll()
..sort((a, b) => b.savedAt.compareTo(a.savedAt));
return books
.take(limit)
.map(
(book) => WidgetShelfBook(
uuid: book.uuid,
id: book.id,
title: book.title,
authors: book.authors,
coverPath: book.coverPath ?? '',
format: book.format,
),
)
.toList();
}
Future<List<WidgetShelfBook>> _loadBookList(int limit) async {
final datasource = BookViewRemoteDatasource(
preferences: prefs,
logger: logger,
);
final books = await datasource.fetchBooks(
offset: 0,
limit: limit,
sortBy: 'added',
sortOrder: 'desc',
);
return books
.take(limit)
.map(
(book) => WidgetShelfBook(
uuid: book.uuid,
id: book.id,
title: book.title,
authors: book.authors,
coverUrl: book.coverUrl ?? '',
format: book.formats.isNotEmpty ? book.formats.first : 'epub',
),
)
.toList();
}
Future<List<WidgetShelfBook>> _loadShelf(
String shelfId,
int limit, {
required bool isMagic,
}) async {
final datasource = ShelfDetailsRemoteDataSource(
apiService: ApiService(),
logger: logger,
preferences: prefs,
);
final books = <WidgetShelfBook>[];
int? offset = 0;
while (offset != null && books.length < limit) {
final details = await datasource.getShelfDetails(
shelfId,
offset: offset,
isMagic: isMagic,
);
if (details.books.isEmpty) break;
books.addAll(
details.books.map(
(book) => WidgetShelfBook(
uuid: book.uuid.toLowerCase().replaceAll('urn:uuid:', ''),
id: 0,
title: book.title,
authors: book.authors,
coverUrl: book.coverUrl ?? '',
format: book.formats.isNotEmpty ? book.formats.first : 'epub',
),
),
);
final next = details.nextOffset;
offset = (next != null && next > offset) ? next : null;
}
return books.take(limit).toList();
}
}
@@ -254,6 +254,7 @@ class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
try {
await repository.setDownloaderEnabled(event.enabled);
emit(state.copyWith(isDownloaderEnabled: event.enabled));
await widgetService.pushQuickActions();
} catch (e) {
emit(
state.copyWith(
@@ -18,6 +18,7 @@ import 'package:calibre_web_companion/features/login_settings/presentation/pages
import 'package:calibre_web_companion/features/settings/presentation/widgets/download_options_widget.dart';
import 'package:calibre_web_companion/features/settings/presentation/widgets/feedback_widget.dart';
import 'package:calibre_web_companion/features/settings/presentation/widgets/theme_selector_widget.dart';
import 'package:calibre_web_companion/features/settings/presentation/widgets/shelf_widget_source_card.dart';
import 'package:calibre_web_companion/features/settings/presentation/widgets/sync_settings_widget.dart';
import 'package:calibre_web_companion/features/settings/presentation/pages/app_logs_page.dart';
import 'package:calibre_web_companion/core/services/widget_service.dart';
@@ -388,6 +389,9 @@ class _SettingsPageState extends State<SettingsPage> {
_buildSectionTitle(context, localizations.widgetTapAction),
_buildWidgetTapTargetCard(context, localizations),
const SizedBox(height: 24),
_buildSectionTitle(context, localizations.widgetShelfSection),
const ShelfWidgetSourceCard(),
const SizedBox(height: 24),
_buildWidgetHowToCard(context, localizations),
],
);
@@ -0,0 +1,246 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:calibre_web_companion/core/services/widget_service.dart';
import 'package:calibre_web_companion/core/services/widget_shelf_loader.dart';
import 'package:calibre_web_companion/features/shelf_view.dart/data/repositories/shelf_view_repository.dart';
import 'package:calibre_web_companion/l10n/app_localizations.dart';
class ShelfWidgetSourceCard extends StatefulWidget {
const ShelfWidgetSourceCard({super.key});
@override
State<ShelfWidgetSourceCard> createState() => _ShelfWidgetSourceCardState();
}
class _ShelfWidgetSourceCardState extends State<ShelfWidgetSourceCard> {
final WidgetService _widgetService = GetIt.instance<WidgetService>();
final ShelfViewRepository _shelfRepository =
GetIt.instance<ShelfViewRepository>();
late WidgetShelfSource _source = _widgetService.shelfSource;
late String _shelfId = _widgetService.shelfId;
Map<String, String>? _shelves;
bool _loadingShelves = false;
String? _shelvesError;
@override
void initState() {
super.initState();
if (_source.needsShelfId) _loadShelves(_source);
}
Future<void> _loadShelves(WidgetShelfSource source) async {
if (!source.needsShelfId) return;
setState(() {
_loadingShelves = true;
_shelvesError = null;
_shelves = null;
});
try {
final Map<String, String> shelves;
if (source == WidgetShelfSource.magicShelf) {
final result = await _shelfRepository.loadMagicShelves();
shelves = {
for (final shelf in result.shelves)
shelf.id:
shelf.icon == null ? shelf.name : '${shelf.icon} ${shelf.name}',
};
} else {
final result = await _shelfRepository.loadShelves();
shelves = {for (final shelf in result.shelves) shelf.id: shelf.title};
}
if (!mounted) return;
setState(() {
_shelves = shelves;
_loadingShelves = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loadingShelves = false;
_shelvesError = e.toString();
});
}
}
Future<void> _selectSource(WidgetShelfSource source) async {
if (source == _source) return;
setState(() {
_source = source;
_shelfId = '';
});
if (source.needsShelfId) {
await _loadShelves(source);
return;
}
await _widgetService.setShelfConfig(
source: source,
label: _sourceLabel(source, AppLocalizations.of(context)!),
);
}
Future<void> _selectShelf(String id, String name) async {
setState(() => _shelfId = id);
await _widgetService.setShelfConfig(source: _source, id: id, label: name);
}
String _sourceLabel(WidgetShelfSource source, AppLocalizations l10n) {
switch (source) {
case WidgetShelfSource.bookList:
return l10n.widgetShelfSourceRecent;
case WidgetShelfSource.shelf:
return l10n.widgetShelfSourceShelf;
case WidgetShelfSource.magicShelf:
return l10n.widgetShelfSourceMagicShelf;
case WidgetShelfSource.offline:
return l10n.widgetShelfSourceOffline;
}
}
IconData _sourceIcon(WidgetShelfSource source) {
switch (source) {
case WidgetShelfSource.bookList:
return Icons.new_releases_rounded;
case WidgetShelfSource.shelf:
return Icons.collections_bookmark_rounded;
case WidgetShelfSource.magicShelf:
return Icons.auto_awesome_rounded;
case WidgetShelfSource.offline:
return Icons.download_done_rounded;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final localizations = AppLocalizations.of(context)!;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 3,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizations.widgetShelfSourceDescription,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
for (final source in WidgetShelfSource.values)
InkWell(
borderRadius: BorderRadius.circular(8.0),
onTap: () => _selectSource(source),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Icon(
_sourceIcon(source),
color: theme.colorScheme.primary,
),
const SizedBox(width: 16),
Expanded(
child: Text(
_sourceLabel(source, localizations),
style: theme.textTheme.titleMedium,
),
),
Icon(
source == _source
? Icons.check_circle_rounded
: Icons.circle_outlined,
color:
source == _source
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
],
),
),
),
if (_source.needsShelfId) ...[
const Divider(height: 24),
_buildShelfPicker(theme, localizations),
],
],
),
),
);
}
Widget _buildShelfPicker(ThemeData theme, AppLocalizations localizations) {
if (_loadingShelves) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Center(child: CircularProgressIndicator()),
);
}
if (_shelvesError != null) {
return Row(
children: [
Icon(Icons.error_outline_rounded, color: theme.colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(
localizations.widgetShelfLoadError,
style: theme.textTheme.bodySmall,
),
),
TextButton(
onPressed: () => _loadShelves(_source),
child: Text(localizations.retry),
),
],
);
}
final shelves = _shelves ?? const <String, String>{};
if (shelves.isEmpty) {
return Text(
localizations.widgetShelfNoneFound,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizations.widgetShelfPick,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final entry in shelves.entries)
ChoiceChip(
label: Text(entry.value),
selected: entry.key == _shelfId,
onSelected: (_) => _selectShelf(entry.key, entry.value),
),
],
),
],
);
}
}
+11 -2
View File
@@ -524,9 +524,18 @@
"tapToChangeIcon": "Zum Ändern tippen",
"change": "Ändern",
"homeWidget": "Startbildschirm-Widget",
"homeWidgetSubtitle": "Aktuelles Buch & Bibliotheksstatistik",
"homeWidgetSubtitle": "Aktuelles Buch, Statistik, Regal & Schnellzugriff",
"widgetShelfSection": "Regal-Widget",
"widgetShelfSourceDescription": "Wähle, welche Bücher das Regal-Widget anzeigt.",
"widgetShelfSourceRecent": "Zuletzt hinzugefügt",
"widgetShelfSourceShelf": "Bücherregal",
"widgetShelfSourceMagicShelf": "Magisches Regal",
"widgetShelfSourceOffline": "Heruntergeladene Bücher",
"widgetShelfPick": "Welches Regal?",
"widgetShelfNoneFound": "Keine Regale gefunden.",
"widgetShelfLoadError": "Regale konnten nicht geladen werden.",
"widgetTapAction": "Beim Antippen des Widgets",
"widgetTapActionDescription": "Wähle, was sich öffnet, wenn du das aktuelle Buch auf dem Startbildschirm antippst.",
"widgetTapActionDescription": "Wähle, was sich öffnet, wenn du ein Buch auf dem Startbildschirm antippst.",
"widgetActionBookDetails": "Buchdetails öffnen",
"widgetActionInternalReader": "Im integrierten Reader öffnen",
"widgetActionExternalReader": "In externem Reader öffnen",
+11 -2
View File
@@ -528,9 +528,18 @@
"tapToChangeIcon": "Tap to change",
"change": "Change",
"homeWidget": "Home screen widget",
"homeWidgetSubtitle": "Current book & library stats",
"homeWidgetSubtitle": "Current book, stats, shelf & quick actions",
"widgetShelfSection": "Shelf widget",
"widgetShelfSourceDescription": "Choose which books the shelf widget shows.",
"widgetShelfSourceRecent": "Recently added",
"widgetShelfSourceShelf": "Shelf",
"widgetShelfSourceMagicShelf": "Magic shelf",
"widgetShelfSourceOffline": "Downloaded books",
"widgetShelfPick": "Which shelf?",
"widgetShelfNoneFound": "No shelves found.",
"widgetShelfLoadError": "Could not load shelves.",
"widgetTapAction": "When tapping the widget",
"widgetTapActionDescription": "Choose what opens when you tap the current book on your home screen.",
"widgetTapActionDescription": "Choose what opens when you tap a book on your home screen.",
"widgetActionBookDetails": "Open book details",
"widgetActionInternalReader": "Open in built-in reader",
"widgetActionExternalReader": "Open in external reader",
+112 -28
View File
@@ -29,8 +29,12 @@ import 'package:calibre_web_companion/features/book_view/bloc/book_view_event.da
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/download_service/bloc/download_service_bloc.dart';
import 'package:calibre_web_companion/features/download_service/bloc/download_service_event.dart';
import 'package:calibre_web_companion/features/book_view/presentation/widgets/search_dialog.dart';
import 'package:calibre_web_companion/features/download_service/bloc/download_service_event.dart'
hide SearchBooks;
import 'package:calibre_web_companion/features/homepage/bloc/homepage_bloc.dart';
import 'package:calibre_web_companion/features/homepage/bloc/homepage_event.dart';
import 'package:calibre_web_companion/features/scan_book/presentation/pages/scan_book_page.dart';
import 'package:calibre_web_companion/features/offline/cubit/connectivity_cubit.dart';
import 'package:calibre_web_companion/features/homepage/presentation/pages/home_page.dart';
import 'package:calibre_web_companion/features/login_settings/bloc/login_settings_event.dart';
@@ -78,6 +82,8 @@ void main() async {
await di.getIt<ApiService>().initialize();
await di.getIt<WidgetService>().registerBackgroundCallback();
await CosmosEpub.initialize();
final savedThemeMode = await AdaptiveTheme.getThemeMode();
@@ -185,56 +191,134 @@ class _MyAppState extends State<MyApp> {
final widgetService = getIt<WidgetService>();
_widgetClickSub = widgetService.widgetClicks.listen(_handleWidgetLaunch);
widgetService.initialWidgetLaunch().then(_handleWidgetLaunch);
widgetService.pushQuickActions();
widgetService.refreshShelf();
}
Future<void> _handleWidgetLaunch(Uri? uri) async {
if (uri == null || uri.scheme != 'calibrewebcompanion') return;
if (uri.pathSegments.contains('stats')) return;
if (uri.pathSegments.contains('stats') ||
uri.pathSegments.contains('shelf')) {
return;
}
if (uri.pathSegments.contains('action')) {
await _handleWidgetAction(uri.queryParameters['do'] ?? '');
return;
}
final widgetService = getIt<WidgetService>();
final target = widgetService.tapTarget;
if (target == WidgetTapTarget.appOnly) return;
final raw = widgetService.currentBookRaw;
if (uri.pathSegments.contains('book')) {
final uuid = uri.queryParameters['uuid'] ?? '';
if (uuid.isEmpty) return;
final matches = widgetService.shelfBooks.where((b) => b.uuid == uuid);
if (matches.isEmpty) return;
final book = matches.first;
await _openWidgetBook(
BookViewModel(
id: book.id,
uuid: book.uuid,
title: book.title,
authors: book.authors,
coverUrl: book.coverUrl.isEmpty ? null : book.coverUrl,
formats: [book.format],
),
);
return;
}
await _openCurrentWidgetBook();
}
Future<void> _openCurrentWidgetBook() async {
final raw = getIt<WidgetService>().currentBookRaw;
if (raw == null) return;
final coverUrl = raw['coverUrl']?.toString() ?? '';
await _openWidgetBook(
BookViewModel(
id: (raw['id'] as num?)?.toInt() ?? 0,
uuid: raw['uuid']?.toString() ?? '',
title: raw['title']?.toString() ?? '',
authors: raw['authors']?.toString() ?? '',
coverUrl: coverUrl.isEmpty ? null : coverUrl,
formats: [raw['format']?.toString() ?? 'epub'],
),
);
}
Future<void> _openWidgetBook(BookViewModel book) async {
final target = getIt<WidgetService>().tapTarget;
if (target == WidgetTapTarget.appOnly) return;
if (book.uuid.isEmpty) return;
final prefs = getIt<SharedPreferences>();
if ((prefs.getString('base_url') ?? '').isEmpty) return;
final coverUrl = raw['coverUrl']?.toString() ?? '';
final book = BookViewModel(
id: (raw['id'] as num?)?.toInt() ?? 0,
uuid: raw['uuid']?.toString() ?? '',
title: raw['title']?.toString() ?? '',
authors: raw['authors']?.toString() ?? '',
coverUrl: coverUrl.isEmpty ? null : coverUrl,
formats: [raw['format']?.toString() ?? 'epub'],
);
if (book.uuid.isEmpty) return;
final autoOpen = switch (target) {
WidgetTapTarget.internalReader => BookAutoOpen.internalReader,
WidgetTapTarget.externalReader => BookAutoOpen.externalReader,
_ => BookAutoOpen.none,
};
final navigator = await _waitForNavigator();
navigator?.push(
AppTransitions.createSlideRoute(
BookDetailsPage(
bookViewModel: book,
bookUuid: book.uuid,
autoOpenAction: autoOpen,
),
),
);
}
Future<void> _handleWidgetAction(String action) async {
if (action == 'read') {
await _openCurrentWidgetBook();
return;
}
final navigator = await _waitForNavigator();
final context = navigatorKey.currentContext;
if (navigator == null || context == null || !context.mounted) return;
switch (action) {
case 'search':
context.read<HomePageBloc>().add(const ChangeNavIndex(0));
final query = await showDialog<String>(
context: context,
builder: (_) => const SearchDialog(),
);
if (query != null && context.mounted) {
context.read<BookViewBloc>().add(SearchBooks(query));
}
case 'scan':
final added = await navigator.push<bool>(
AppTransitions.createSlideRoute(const ScanBookPage()),
);
if (added == true && context.mounted) {
context.read<BookViewBloc>().add(const RefreshBooks());
}
case 'downloads':
final showsDiscover =
getIt<SharedPreferences>().getString('server_type') != 'calibre';
context.read<HomePageBloc>().add(ChangeNavIndex(showsDiscover ? 3 : 2));
}
}
Future<NavigatorState?> _waitForNavigator() async {
for (var attempt = 0; attempt < 20; attempt++) {
final navigator = navigatorKey.currentState;
if (navigator != null) {
navigator.push(
AppTransitions.createSlideRoute(
BookDetailsPage(
bookViewModel: book,
bookUuid: book.uuid,
autoOpenAction: autoOpen,
),
),
);
return;
}
if (navigator != null) return navigator;
await Future.delayed(const Duration(milliseconds: 150));
}
return null;
}
Future<bool> _isLoggedIn() async {