diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8852fbe..8834df8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,10 +27,6 @@ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> - - + + + + + + + + + + + + + + + + + + + + + + + + - diff --git a/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/CurrentBookWidgetProvider.kt b/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/CurrentBookWidgetProvider.kt index a8ceaeb..f05bd6b 100644 --- a/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/CurrentBookWidgetProvider.kt +++ b/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/CurrentBookWidgetProvider.kt @@ -3,13 +3,6 @@ package de.doen1el.calibreWebCompanion import android.appwidget.AppWidgetManager import android.content.Context import android.content.SharedPreferences -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.BitmapShader -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.RectF -import android.graphics.Shader import android.net.Uri import android.view.View import android.widget.RemoteViews @@ -44,13 +37,16 @@ class CurrentBookWidgetProvider : HomeWidgetProvider() { val decoded = if (coverPath.isNotEmpty() && File(coverPath).exists()) { - decodeScaledCover(coverPath) + WidgetImages.decodeScaledCover(coverPath) } else { null } if (decoded != null) { val radius = 10f * context.resources.displayMetrics.density - views.setImageViewBitmap(R.id.widget_cover, roundBitmap(decoded, radius)) + views.setImageViewBitmap( + R.id.widget_cover, + WidgetImages.roundBitmap(decoded, radius) + ) } else { views.setImageViewResource(R.id.widget_cover, R.drawable.widget_cover_placeholder) } @@ -80,40 +76,4 @@ class CurrentBookWidgetProvider : HomeWidgetProvider() { appWidgetManager.updateAppWidget(widgetId, views) } } - - private fun decodeScaledCover(path: String, maxDimen: Int = 512): Bitmap? { - val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeFile(path, bounds) - if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null - - var sampleSize = 1 - val longestEdge = maxOf(bounds.outWidth, bounds.outHeight) - while (longestEdge / sampleSize > maxDimen * 2) { - sampleSize *= 2 - } - - val options = BitmapFactory.Options().apply { inSampleSize = sampleSize } - val decoded = BitmapFactory.decodeFile(path, options) ?: return null - - val longestDecoded = maxOf(decoded.width, decoded.height) - if (longestDecoded <= maxDimen) return decoded - - val scale = maxDimen.toFloat() / longestDecoded - val width = (decoded.width * scale).toInt().coerceAtLeast(1) - val height = (decoded.height * scale).toInt().coerceAtLeast(1) - val scaled = Bitmap.createScaledBitmap(decoded, width, height, true) - if (scaled != decoded) decoded.recycle() - return scaled - } - - private fun roundBitmap(src: Bitmap, radius: Float): Bitmap { - val output = Bitmap.createBitmap(src.width, src.height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(output) - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - shader = BitmapShader(src, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) - } - val rect = RectF(0f, 0f, src.width.toFloat(), src.height.toFloat()) - canvas.drawRoundRect(rect, radius, radius, paint) - return output - } } diff --git a/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/MainActivity.kt b/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/MainActivity.kt index 8b4eef1..e772b57 100644 --- a/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/MainActivity.kt +++ b/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/MainActivity.kt @@ -1,5 +1,32 @@ package de.doen1el.calibreWebCompanion +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import es.antonborri.home_widget.HomeWidgetLaunchIntent import io.flutter.embedding.android.FlutterActivity -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + restoreWidgetUri(intent) + super.onCreate(savedInstanceState) + } + + override fun onNewIntent(intent: Intent) { + restoreWidgetUri(intent) + super.onNewIntent(intent) + } + + private fun restoreWidgetUri(intent: Intent?) { + if (intent == null) return + if (intent.action != HomeWidgetLaunchIntent.HOME_WIDGET_LAUNCH_ACTION) return + if (intent.data != null) return + + val uri = intent.getStringExtra(EXTRA_WIDGET_URI) ?: return + intent.data = Uri.parse(uri) + } + + companion object { + const val EXTRA_WIDGET_URI = "widget_uri" + } +} diff --git a/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/QuickActionsWidgetProvider.kt b/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/QuickActionsWidgetProvider.kt new file mode 100644 index 0000000..73541fd --- /dev/null +++ b/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/QuickActionsWidgetProvider.kt @@ -0,0 +1,69 @@ +package de.doen1el.calibreWebCompanion + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.SharedPreferences +import android.net.Uri +import android.view.View +import android.widget.RemoteViews +import es.antonborri.home_widget.HomeWidgetLaunchIntent +import es.antonborri.home_widget.HomeWidgetProvider + +class QuickActionsWidgetProvider : HomeWidgetProvider() { + + private data class Action( + val slotId: Int, + val tileId: Int, + val iconId: Int, + val labelId: Int, + val key: String + ) + + private val actions = listOf( + Action(R.id.qa_slot_search, R.id.qa_tile_search, R.id.qa_icon_search, R.id.qa_label_search, "search"), + Action(R.id.qa_slot_scan, R.id.qa_tile_scan, R.id.qa_icon_scan, R.id.qa_label_scan, "scan"), + Action(R.id.qa_slot_read, R.id.qa_tile_read, R.id.qa_icon_read, R.id.qa_label_read, "read"), + Action(R.id.qa_slot_downloads, R.id.qa_tile_downloads, R.id.qa_icon_downloads, R.id.qa_label_downloads, "downloads") + ) + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + widgetData: SharedPreferences + ) { + val downloadsEnabled = widgetData.getString("qa_downloads", "0") == "1" + val palette = WidgetTheming.palette(context, widgetData) + + for (widgetId in appWidgetIds) { + val views = RemoteViews(context.packageName, R.layout.widget_quick_actions) + + palette?.let { views.setInt(R.id.widget_bg, "setColorFilter", it.background) } + + for (action in actions) { + if (action.key == "downloads" && !downloadsEnabled) { + views.setViewVisibility(action.slotId, View.GONE) + continue + } + + views.setViewVisibility(action.slotId, View.VISIBLE) + views.setOnClickPendingIntent( + action.slotId, + HomeWidgetLaunchIntent.getActivity( + context, + MainActivity::class.java, + Uri.parse("calibrewebcompanion://widget/action?do=${action.key}") + ) + ) + + palette?.let { p -> + views.setInt(action.tileId, "setColorFilter", p.tile) + views.setInt(action.iconId, "setColorFilter", p.onTile) + views.setTextColor(action.labelId, p.onBackground) + } + } + + appWidgetManager.updateAppWidget(widgetId, views) + } + } +} diff --git a/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/ShelfWidgetProvider.kt b/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/ShelfWidgetProvider.kt new file mode 100644 index 0000000..e3f2214 --- /dev/null +++ b/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/ShelfWidgetProvider.kt @@ -0,0 +1,79 @@ +package de.doen1el.calibreWebCompanion + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import android.widget.RemoteViews +import es.antonborri.home_widget.HomeWidgetBackgroundIntent +import es.antonborri.home_widget.HomeWidgetLaunchIntent +import es.antonborri.home_widget.HomeWidgetProvider + +class ShelfWidgetProvider : HomeWidgetProvider() { + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + widgetData: SharedPreferences + ) { + for (widgetId in appWidgetIds) { + val views = RemoteViews(context.packageName, R.layout.widget_shelf) + + val title = widgetData.getString("sh_title", "") ?: "" + views.setTextViewText( + R.id.shelf_title, + if (title.isEmpty()) context.getString(R.string.widget_shelf_recent) else title + ) + + val adapterIntent = Intent(context, ShelfWidgetService::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) + // Makes the intent unique per widget so each keeps its own adapter. + data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME)) + } + views.setRemoteAdapter(R.id.shelf_grid, adapterIntent) + views.setEmptyView(R.id.shelf_grid, R.id.shelf_empty) + + val template = Intent(context, MainActivity::class.java).apply { + action = HomeWidgetLaunchIntent.HOME_WIDGET_LAUNCH_ACTION + } + views.setPendingIntentTemplate( + R.id.shelf_grid, + PendingIntent.getActivity( + context, + 0, + template, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + ) + ) + + WidgetTheming.palette(context, widgetData)?.let { p -> + views.setInt(R.id.widget_bg, "setColorFilter", p.background) + views.setTextColor(R.id.shelf_title, p.onBackground) + views.setTextColor(R.id.shelf_empty, WidgetTheming.muted(p.onBackground)) + views.setInt(R.id.shelf_refresh, "setColorFilter", p.accent) + } + + // Reloads the shelf in a background isolate, without opening the app. + val refresh = HomeWidgetBackgroundIntent.getBroadcast( + context, + Uri.parse("calibrewebcompanion://widget/refresh") + ) + views.setOnClickPendingIntent(R.id.shelf_refresh, refresh) + views.setOnClickPendingIntent(R.id.shelf_empty, refresh) + + views.setOnClickPendingIntent( + R.id.shelf_header, + HomeWidgetLaunchIntent.getActivity( + context, + MainActivity::class.java, + Uri.parse("calibrewebcompanion://widget/shelf") + ) + ) + + appWidgetManager.updateAppWidget(widgetId, views) + appWidgetManager.notifyAppWidgetViewDataChanged(widgetId, R.id.shelf_grid) + } + } +} diff --git a/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/ShelfWidgetService.kt b/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/ShelfWidgetService.kt new file mode 100644 index 0000000..a3f082a --- /dev/null +++ b/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/ShelfWidgetService.kt @@ -0,0 +1,122 @@ +package de.doen1el.calibreWebCompanion + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.RemoteViews +import android.widget.RemoteViewsService +import es.antonborri.home_widget.HomeWidgetPlugin +import org.json.JSONArray +import java.io.File +import kotlin.math.sqrt + +private const val MIN_COVER_DIMEN = 96 +private const val MAX_COVER_DIMEN = 256 + +class ShelfWidgetService : RemoteViewsService() { + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory = + ShelfRemoteViewsFactory(applicationContext) +} + +private data class ShelfItem( + val uuid: String, + val title: String, + val authors: String, + val cover: String +) + +private class ShelfRemoteViewsFactory(private val context: Context) : + RemoteViewsService.RemoteViewsFactory { + + private var items: List = emptyList() + private var palette: WidgetTheming.Palette? = null + private var coverDimen: Int = MAX_COVER_DIMEN + + override fun onCreate() = Unit + + override fun onDataSetChanged() { + val data = HomeWidgetPlugin.getData(context) + items = parse(data.getString("sh_json", "") ?: "") + palette = WidgetTheming.palette(context, data) + coverDimen = coverDimenFor(items.size) + } + + private fun coverDimenFor(count: Int): Int { + if (count <= 0) return MAX_COVER_DIMEN + + val metrics = context.resources.displayMetrics + val hostLimit = + metrics.widthPixels.toLong() * metrics.heightPixels.toLong() * 4L * 3L / 2L + val perItem = hostLimit * 30 / 100 / count + val height = sqrt(perItem / 2.7).toInt() + + return height.coerceIn(MIN_COVER_DIMEN, MAX_COVER_DIMEN) + } + + override fun onDestroy() { + items = emptyList() + } + + override fun getCount(): Int = items.size + + override fun getViewAt(position: Int): RemoteViews { + val views = RemoteViews(context.packageName, R.layout.widget_shelf_item) + val item = items.getOrNull(position) ?: return views + + views.setTextViewText(R.id.item_title, item.title) + views.setTextViewText(R.id.item_authors, item.authors) + views.setContentDescription(R.id.item_cover, item.title) + + val decoded = + if (item.cover.isNotEmpty() && File(item.cover).exists()) { + WidgetImages.decodeScaledCover(item.cover, maxDimen = coverDimen) + } else { + null + } + if (decoded != null) { + val radius = 8f * context.resources.displayMetrics.density + views.setImageViewBitmap(R.id.item_cover, WidgetImages.roundBitmap(decoded, radius)) + } else { + views.setImageViewResource(R.id.item_cover, R.drawable.widget_cover_placeholder) + } + + palette?.let { p -> + views.setTextColor(R.id.item_title, p.onBackground) + views.setTextColor(R.id.item_authors, WidgetTheming.muted(p.onBackground)) + } + + val target = "calibrewebcompanion://widget/book?uuid=${item.uuid}" + views.setOnClickFillInIntent( + R.id.item_root, + Intent() + .setData(Uri.parse(target)) + .putExtra(MainActivity.EXTRA_WIDGET_URI, target) + ) + + return views + } + + override fun getLoadingView(): RemoteViews? = null + + override fun getViewTypeCount(): Int = 1 + + override fun getItemId(position: Int): Long = position.toLong() + + override fun hasStableIds(): Boolean = true + + private fun parse(json: String): List { + if (json.isEmpty()) return emptyList() + return runCatching { + val array = JSONArray(json) + (0 until array.length()).mapNotNull { index -> + val entry = array.optJSONObject(index) ?: return@mapNotNull null + ShelfItem( + uuid = entry.optString("uuid"), + title = entry.optString("title"), + authors = entry.optString("authors"), + cover = entry.optString("cover") + ) + } + }.getOrDefault(emptyList()) + } +} diff --git a/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/WidgetImages.kt b/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/WidgetImages.kt new file mode 100644 index 0000000..0f9a6b4 --- /dev/null +++ b/android/app/src/main/kotlin/de/doen1el/calibreWebCompanion/WidgetImages.kt @@ -0,0 +1,47 @@ +package de.doen1el.calibreWebCompanion + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Shader + +object WidgetImages { + fun decodeScaledCover(path: String, maxDimen: Int = 512): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + var sampleSize = 1 + val longestEdge = maxOf(bounds.outWidth, bounds.outHeight) + while (longestEdge / sampleSize > maxDimen * 2) { + sampleSize *= 2 + } + + val options = BitmapFactory.Options().apply { inSampleSize = sampleSize } + val decoded = BitmapFactory.decodeFile(path, options) ?: return null + + val longestDecoded = maxOf(decoded.width, decoded.height) + if (longestDecoded <= maxDimen) return decoded + + val scale = maxDimen.toFloat() / longestDecoded + val width = (decoded.width * scale).toInt().coerceAtLeast(1) + val height = (decoded.height * scale).toInt().coerceAtLeast(1) + val scaled = Bitmap.createScaledBitmap(decoded, width, height, true) + if (scaled != decoded) decoded.recycle() + return scaled + } + + fun roundBitmap(src: Bitmap, radius: Float): Bitmap { + val output = Bitmap.createBitmap(src.width, src.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + shader = BitmapShader(src, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + } + val rect = RectF(0f, 0f, src.width.toFloat(), src.height.toFloat()) + canvas.drawRoundRect(rect, radius, radius, paint) + return output + } +} diff --git a/android/app/src/main/res/drawable/ic_widget_book.xml b/android/app/src/main/res/drawable/ic_widget_book.xml new file mode 100644 index 0000000..8a2bda0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_book.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_widget_download.xml b/android/app/src/main/res/drawable/ic_widget_download.xml new file mode 100644 index 0000000..2c0ca6a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_download.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_widget_refresh.xml b/android/app/src/main/res/drawable/ic_widget_refresh.xml new file mode 100644 index 0000000..7e2c548 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_refresh.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_widget_scan.xml b/android/app/src/main/res/drawable/ic_widget_scan.xml new file mode 100644 index 0000000..573c38e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_scan.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_widget_search.xml b/android/app/src/main/res/drawable/ic_widget_search.xml new file mode 100644 index 0000000..88f8efb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_search.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/layout/widget_quick_actions.xml b/android/app/src/main/res/layout/widget_quick_actions.xml new file mode 100644 index 0000000..afda30b --- /dev/null +++ b/android/app/src/main/res/layout/widget_quick_actions.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_shelf.xml b/android/app/src/main/res/layout/widget_shelf.xml new file mode 100644 index 0000000..8892260 --- /dev/null +++ b/android/app/src/main/res/layout/widget_shelf.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_shelf_item.xml b/android/app/src/main/res/layout/widget_shelf_item.xml new file mode 100644 index 0000000..5c735fa --- /dev/null +++ b/android/app/src/main/res/layout/widget_shelf_item.xml @@ -0,0 +1,37 @@ + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 38c44a3..d2f5a0d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -10,4 +10,17 @@ Authors Categories Series + + Shelf + A grid of books from a shelf, the library or your downloads + Recently added + No books yet — tap to refresh + Refresh + + Quick actions + Search, scan and keep reading + Search + Scan + Read + Downloads diff --git a/android/app/src/main/res/xml/quick_actions_widget_info.xml b/android/app/src/main/res/xml/quick_actions_widget_info.xml new file mode 100644 index 0000000..f9b5060 --- /dev/null +++ b/android/app/src/main/res/xml/quick_actions_widget_info.xml @@ -0,0 +1,12 @@ + + diff --git a/android/app/src/main/res/xml/shelf_widget_info.xml b/android/app/src/main/res/xml/shelf_widget_info.xml new file mode 100644 index 0000000..5fed9fe --- /dev/null +++ b/android/app/src/main/res/xml/shelf_widget_info.xml @@ -0,0 +1,12 @@ + + diff --git a/lib/core/services/widget_background.dart b/lib/core/services/widget_background.dart new file mode 100644 index 0000000..f8fca76 --- /dev/null +++ b/lib/core/services/widget_background.dart @@ -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 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(); +} diff --git a/lib/core/services/widget_service.dart b/lib/core/services/widget_service.dart index c5b237e..6ef86c7 100644 --- a/lib/core/services/widget_service.dart +++ b/lib/core/services/widget_service.dart @@ -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 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 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(WidgetShelfBook.fromJson) + .toList(); + } catch (_) { + return const []; + } + } + Map? get currentBookRaw { final raw = prefs.getString(_kCurrentBookKey); if (raw == null || raw.isEmpty) return null; @@ -206,6 +248,74 @@ class WidgetService { } } + Future refreshShelf() async { + if (!_supported) return; + + final source = shelfSource; + final id = shelfId; + + List 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('sh_title', shelfLabel); + await HomeWidget.saveWidgetData( + '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 pushQuickActions() async { + if (!_supported) return; + try { + final downloaderEnabled = prefs.getBool('downloader_enabled') ?? false; + await HomeWidget.saveWidgetData( + 'qa_downloads', + downloaderEnabled ? '1' : '0', + ); + await HomeWidget.updateWidget(androidName: _quickActionsProvider); + } catch (e) { + logger.w('Failed to push quick actions widget: $e'); + } + } + Future 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 registerBackgroundCallback() async { + if (!_supported) return; + try { + await HomeWidget.registerInteractivityCallback(widgetBackgroundCallback); + } catch (e) { + logger.w('Failed to register widget background callback: $e'); + } + } + Stream get widgetClicks => HomeWidget.widgetClicked; Future initialWidgetLaunch() => @@ -315,6 +436,56 @@ class WidgetService { return '$baseUrl/opds/cover/$id'; } + Future> _resolveCovers( + List books, + ) async { + const batchSize = 6; + final resolved = []; + + 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 _pruneWidgetCovers(List keep) async { + try { + final supportDir = await getApplicationSupportDirectory(); + final widgetDir = Directory(p.join(supportDir.path, 'widget')); + if (!await widgetDir.exists()) return; + + final keepPaths = { + 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 _copyToWidgetDir(String uuid, File source) async { try { final supportDir = await getApplicationSupportDirectory(); diff --git a/lib/core/services/widget_shelf_loader.dart b/lib/core/services/widget_shelf_loader.dart new file mode 100644 index 0000000..5722010 --- /dev/null +++ b/lib/core/services/widget_shelf_loader.dart @@ -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 toJson() => { + 'uuid': uuid, + 'id': id, + 'title': title, + 'authors': authors, + 'coverUrl': coverUrl, + 'coverPath': coverPath, + 'format': format, + }; + + factory WidgetShelfBook.fromJson(Map 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> 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 _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> _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> _loadShelf( + String shelfId, + int limit, { + required bool isMagic, + }) async { + final datasource = ShelfDetailsRemoteDataSource( + apiService: ApiService(), + logger: logger, + preferences: prefs, + ); + + final books = []; + 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(); + } +} diff --git a/lib/features/settings/bloc/settings_bloc.dart b/lib/features/settings/bloc/settings_bloc.dart index 359581d..5b432c2 100644 --- a/lib/features/settings/bloc/settings_bloc.dart +++ b/lib/features/settings/bloc/settings_bloc.dart @@ -254,6 +254,7 @@ class SettingsBloc extends Bloc { try { await repository.setDownloaderEnabled(event.enabled); emit(state.copyWith(isDownloaderEnabled: event.enabled)); + await widgetService.pushQuickActions(); } catch (e) { emit( state.copyWith( diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index a920d1b..7f8fb58 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -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 { _buildSectionTitle(context, localizations.widgetTapAction), _buildWidgetTapTargetCard(context, localizations), const SizedBox(height: 24), + _buildSectionTitle(context, localizations.widgetShelfSection), + const ShelfWidgetSourceCard(), + const SizedBox(height: 24), _buildWidgetHowToCard(context, localizations), ], ); diff --git a/lib/features/settings/presentation/widgets/shelf_widget_source_card.dart b/lib/features/settings/presentation/widgets/shelf_widget_source_card.dart new file mode 100644 index 0000000..453d228 --- /dev/null +++ b/lib/features/settings/presentation/widgets/shelf_widget_source_card.dart @@ -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 createState() => _ShelfWidgetSourceCardState(); +} + +class _ShelfWidgetSourceCardState extends State { + final WidgetService _widgetService = GetIt.instance(); + final ShelfViewRepository _shelfRepository = + GetIt.instance(); + + late WidgetShelfSource _source = _widgetService.shelfSource; + late String _shelfId = _widgetService.shelfId; + + Map? _shelves; + bool _loadingShelves = false; + String? _shelvesError; + + @override + void initState() { + super.initState(); + if (_source.needsShelfId) _loadShelves(_source); + } + + Future _loadShelves(WidgetShelfSource source) async { + if (!source.needsShelfId) return; + + setState(() { + _loadingShelves = true; + _shelvesError = null; + _shelves = null; + }); + + try { + final Map 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 _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 _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 {}; + 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), + ), + ], + ), + ], + ); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 26903ba..4369438 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4dab177..804e0ae 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/main.dart b/lib/main.dart index 8c06574..fac27f0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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().initialize(); + await di.getIt().registerBackgroundCallback(); + await CosmosEpub.initialize(); final savedThemeMode = await AdaptiveTheme.getThemeMode(); @@ -185,56 +191,134 @@ class _MyAppState extends State { final widgetService = getIt(); _widgetClickSub = widgetService.widgetClicks.listen(_handleWidgetLaunch); widgetService.initialWidgetLaunch().then(_handleWidgetLaunch); + + widgetService.pushQuickActions(); + widgetService.refreshShelf(); } Future _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(); - 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 _openCurrentWidgetBook() async { + final raw = getIt().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 _openWidgetBook(BookViewModel book) async { + final target = getIt().tapTarget; + if (target == WidgetTapTarget.appOnly) return; + if (book.uuid.isEmpty) return; + final prefs = getIt(); 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 _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().add(const ChangeNavIndex(0)); + final query = await showDialog( + context: context, + builder: (_) => const SearchDialog(), + ); + if (query != null && context.mounted) { + context.read().add(SearchBooks(query)); + } + case 'scan': + final added = await navigator.push( + AppTransitions.createSlideRoute(const ScanBookPage()), + ); + if (added == true && context.mounted) { + context.read().add(const RefreshBooks()); + } + case 'downloads': + final showsDiscover = + getIt().getString('server_type') != 'calibre'; + context.read().add(ChangeNavIndex(showsDiscover ? 3 : 2)); + } + } + + Future _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 _isLoggedIn() async {