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
+42 -10
View File
@@ -27,10 +27,6 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
@@ -65,17 +61,53 @@
android:resource="@xml/library_stats_widget_info" />
</receiver>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<receiver
android:name=".ShelfWidgetProvider"
android:exported="true"
android:label="@string/widget_shelf_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/shelf_widget_info" />
</receiver>
<receiver
android:name=".QuickActionsWidgetProvider"
android:exported="true"
android:label="@string/widget_quick_actions_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/quick_actions_widget_info" />
</receiver>
<service
android:name=".ShelfWidgetService"
android:exported="false"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<receiver
android:name="es.antonborri.home_widget.HomeWidgetBackgroundReceiver"
android:exported="true">
<intent-filter>
<action android:name="es.antonborri.home_widget.action.BACKGROUND" />
</intent-filter>
</receiver>
<service
android:name="es.antonborri.home_widget.HomeWidgetBackgroundService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
@@ -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
}
}
@@ -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"
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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<ShelfItem> = 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<ShelfItem> {
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())
}
}
@@ -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
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF1A1C18"
android:pathData="M18,2H6c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,4h5v8l-2.5,-1.5L6,12V4z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF1A1C18"
android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF1A1C18"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -8,8s3.57,8 8,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF1A1C18"
android:pathData="M3,5v4h2V5h4V3H5C3.9,3 3,3.9 3,5zM5,15H3v4c0,1.1 0.9,2 2,2h4v-2H5V15zM19,19h-4v2h4c1.1,0 2,-0.9 2,-2v-4h-2V19zM19,3h-4v2h4v4h2V5C21,3.9 20.1,3 19,3z" />
<path
android:fillColor="#FF1A1C18"
android:pathData="M4,11h16v2H4z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF1A1C18"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
</vector>
@@ -0,0 +1,186 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/qa_root"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/widget_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="fitXY"
android:src="@drawable/widget_background" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp">
<LinearLayout
android:id="@+id/qa_slot_search"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<FrameLayout
android:layout_width="44dp"
android:layout_height="44dp">
<ImageView
android:id="@+id/qa_tile_search"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="fitXY"
android:src="@drawable/widget_tile_background" />
<ImageView
android:id="@+id/qa_icon_search"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_gravity="center"
android:contentDescription="@string/widget_action_search"
android:src="@drawable/ic_widget_search" />
</FrameLayout>
<TextView
android:id="@+id/qa_label_search"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_action_search"
android:textColor="@color/widget_text_primary"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/qa_slot_scan"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<FrameLayout
android:layout_width="44dp"
android:layout_height="44dp">
<ImageView
android:id="@+id/qa_tile_scan"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="fitXY"
android:src="@drawable/widget_tile_background" />
<ImageView
android:id="@+id/qa_icon_scan"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_gravity="center"
android:contentDescription="@string/widget_action_scan"
android:src="@drawable/ic_widget_scan" />
</FrameLayout>
<TextView
android:id="@+id/qa_label_scan"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_action_scan"
android:textColor="@color/widget_text_primary"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/qa_slot_read"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<FrameLayout
android:layout_width="44dp"
android:layout_height="44dp">
<ImageView
android:id="@+id/qa_tile_read"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="fitXY"
android:src="@drawable/widget_tile_background" />
<ImageView
android:id="@+id/qa_icon_read"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_gravity="center"
android:contentDescription="@string/widget_action_read"
android:src="@drawable/ic_widget_book" />
</FrameLayout>
<TextView
android:id="@+id/qa_label_read"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_action_read"
android:textColor="@color/widget_text_primary"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/qa_slot_downloads"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<FrameLayout
android:layout_width="44dp"
android:layout_height="44dp">
<ImageView
android:id="@+id/qa_tile_downloads"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="fitXY"
android:src="@drawable/widget_tile_background" />
<ImageView
android:id="@+id/qa_icon_downloads"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_gravity="center"
android:contentDescription="@string/widget_action_downloads"
android:src="@drawable/ic_widget_download" />
</FrameLayout>
<TextView
android:id="@+id/qa_label_downloads"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_action_downloads"
android:textColor="@color/widget_text_primary"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/shelf_root"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/widget_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="fitXY"
android:src="@drawable/widget_background" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:id="@+id/shelf_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/shelf_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/widget_shelf_recent"
android:textColor="@color/widget_text_primary"
android:textSize="13sp"
android:textStyle="bold" />
<ImageView
android:id="@+id/shelf_refresh"
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="@string/widget_shelf_refresh"
android:src="@drawable/ic_widget_refresh" />
</LinearLayout>
<TextView
android:id="@+id/shelf_empty"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:padding="12dp"
android:text="@string/widget_shelf_empty"
android:textColor="@color/widget_text_secondary"
android:textSize="13sp"
android:visibility="gone" />
<GridView
android:id="@+id/shelf_grid"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:columnWidth="96dp"
android:horizontalSpacing="6dp"
android:numColumns="auto_fit"
android:stretchMode="columnWidth"
android:verticalSpacing="8dp" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/item_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="2dp"
android:paddingEnd="2dp">
<ImageView
android:id="@+id/item_cover"
android:layout_width="match_parent"
android:layout_height="130dp"
android:contentDescription="@null"
android:scaleType="centerCrop"
android:src="@drawable/widget_cover_placeholder" />
<TextView
android:id="@+id/item_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="@color/widget_text_primary"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/item_authors"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/widget_text_secondary"
android:textSize="10sp" />
</LinearLayout>
@@ -10,4 +10,17 @@
<string name="widget_stat_authors">Authors</string>
<string name="widget_stat_categories">Categories</string>
<string name="widget_stat_series">Series</string>
<string name="widget_shelf_label">Shelf</string>
<string name="widget_shelf_description">A grid of books from a shelf, the library or your downloads</string>
<string name="widget_shelf_recent">Recently added</string>
<string name="widget_shelf_empty">No books yet — tap to refresh</string>
<string name="widget_shelf_refresh">Refresh</string>
<string name="widget_quick_actions_label">Quick actions</string>
<string name="widget_quick_actions_description">Search, scan and keep reading</string>
<string name="widget_action_search">Search</string>
<string name="widget_action_scan">Scan</string>
<string name="widget_action_read">Read</string>
<string name="widget_action_downloads">Downloads</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="70dp"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:description="@string/widget_quick_actions_description"
android:initialLayout="@layout/widget_quick_actions"
android:previewLayout="@layout/widget_quick_actions"
android:resizeMode="horizontal"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen" />
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="200dp"
android:targetCellWidth="4"
android:targetCellHeight="4"
android:description="@string/widget_shelf_description"
android:initialLayout="@layout/widget_shelf"
android:previewLayout="@layout/widget_shelf"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen" />
+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 {