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