feature: added two more widget types (#172)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user