feature: begon to work on the homescreen widgets
This commit is contained in:
@@ -40,6 +40,31 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".CurrentBookWidgetProvider"
|
||||
android:exported="true"
|
||||
android:label="@string/widget_current_book_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/current_book_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".LibraryStatsWidgetProvider"
|
||||
android:exported="true"
|
||||
android:label="@string/widget_stats_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
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 -->
|
||||
<meta-data
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
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
|
||||
import es.antonborri.home_widget.HomeWidgetLaunchIntent
|
||||
import es.antonborri.home_widget.HomeWidgetProvider
|
||||
import java.io.File
|
||||
|
||||
class CurrentBookWidgetProvider : HomeWidgetProvider() {
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray,
|
||||
widgetData: SharedPreferences
|
||||
) {
|
||||
for (widgetId in appWidgetIds) {
|
||||
val views = RemoteViews(context.packageName, R.layout.widget_current_book)
|
||||
|
||||
val uuid = widgetData.getString("cb_uuid", "") ?: ""
|
||||
val title = widgetData.getString("cb_title", "") ?: ""
|
||||
val authors = widgetData.getString("cb_authors", "") ?: ""
|
||||
val coverPath = widgetData.getString("cb_cover", "") ?: ""
|
||||
val progress = (widgetData.getString("cb_progress", "0") ?: "0").toIntOrNull() ?: 0
|
||||
|
||||
if (uuid.isEmpty()) {
|
||||
views.setViewVisibility(R.id.widget_empty, View.VISIBLE)
|
||||
views.setViewVisibility(R.id.widget_content, View.GONE)
|
||||
} else {
|
||||
views.setViewVisibility(R.id.widget_empty, View.GONE)
|
||||
views.setViewVisibility(R.id.widget_content, View.VISIBLE)
|
||||
views.setTextViewText(R.id.widget_title, title)
|
||||
views.setTextViewText(R.id.widget_authors, authors)
|
||||
|
||||
val decoded =
|
||||
if (coverPath.isNotEmpty() && File(coverPath).exists()) {
|
||||
decodeScaledCover(coverPath)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (decoded != null) {
|
||||
val radius = 10f * context.resources.displayMetrics.density
|
||||
views.setImageViewBitmap(R.id.widget_cover, roundBitmap(decoded, radius))
|
||||
} else {
|
||||
views.setImageViewResource(R.id.widget_cover, R.drawable.widget_cover_placeholder)
|
||||
}
|
||||
|
||||
if (progress in 1..99) {
|
||||
views.setViewVisibility(R.id.widget_percent, View.VISIBLE)
|
||||
views.setTextViewText(R.id.widget_percent, "$progress%")
|
||||
} else {
|
||||
views.setViewVisibility(R.id.widget_percent, View.GONE)
|
||||
}
|
||||
}
|
||||
|
||||
WidgetTheming.apply(
|
||||
context,
|
||||
views,
|
||||
widgetData,
|
||||
R.id.widget_bg,
|
||||
intArrayOf(R.id.widget_title, R.id.widget_empty),
|
||||
intArrayOf(R.id.widget_authors)
|
||||
)
|
||||
|
||||
val uri = Uri.parse("calibrewebcompanion://widget/current?uuid=$uuid")
|
||||
val pendingIntent =
|
||||
HomeWidgetLaunchIntent.getActivity(context, MainActivity::class.java, uri)
|
||||
views.setOnClickPendingIntent(R.id.widget_root, pendingIntent)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package de.doen1el.calibreWebCompanion
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.widget.RemoteViews
|
||||
import es.antonborri.home_widget.HomeWidgetLaunchIntent
|
||||
import es.antonborri.home_widget.HomeWidgetProvider
|
||||
|
||||
class LibraryStatsWidgetProvider : HomeWidgetProvider() {
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray,
|
||||
widgetData: SharedPreferences
|
||||
) {
|
||||
for (widgetId in appWidgetIds) {
|
||||
val views = RemoteViews(context.packageName, R.layout.widget_library_stats)
|
||||
|
||||
views.setTextViewText(R.id.stat_books_value, widgetData.getString("st_books", "0"))
|
||||
views.setTextViewText(R.id.stat_authors_value, widgetData.getString("st_authors", "0"))
|
||||
views.setTextViewText(
|
||||
R.id.stat_categories_value,
|
||||
widgetData.getString("st_categories", "0")
|
||||
)
|
||||
views.setTextViewText(R.id.stat_series_value, widgetData.getString("st_series", "0"))
|
||||
|
||||
WidgetTheming.apply(
|
||||
context,
|
||||
views,
|
||||
widgetData,
|
||||
R.id.widget_bg,
|
||||
intArrayOf(
|
||||
R.id.stat_books_value,
|
||||
R.id.stat_authors_value,
|
||||
R.id.stat_categories_value,
|
||||
R.id.stat_series_value
|
||||
),
|
||||
intArrayOf(
|
||||
R.id.stats_label,
|
||||
R.id.stat_books_label,
|
||||
R.id.stat_authors_label,
|
||||
R.id.stat_categories_label,
|
||||
R.id.stat_series_label
|
||||
)
|
||||
)
|
||||
|
||||
val uri = Uri.parse("calibrewebcompanion://widget/stats")
|
||||
val pendingIntent =
|
||||
HomeWidgetLaunchIntent.getActivity(context, MainActivity::class.java, uri)
|
||||
views.setOnClickPendingIntent(R.id.stats_root, pendingIntent)
|
||||
|
||||
appWidgetManager.updateAppWidget(widgetId, views)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.doen1el.calibreWebCompanion
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.widget.RemoteViews
|
||||
|
||||
object WidgetTheming {
|
||||
fun apply(
|
||||
context: Context,
|
||||
views: RemoteViews,
|
||||
widgetData: SharedPreferences,
|
||||
bgViewId: Int,
|
||||
primaryTextIds: IntArray,
|
||||
secondaryTextIds: IntArray
|
||||
) {
|
||||
val night = (context.resources.configuration.uiMode and
|
||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||
val suffix = if (night) "dark" else "light"
|
||||
|
||||
val bg = widgetData.getString("th_bg_$suffix", null)
|
||||
val onBg = widgetData.getString("th_on_bg_$suffix", null)
|
||||
if (bg.isNullOrEmpty() || onBg.isNullOrEmpty()) return
|
||||
|
||||
val bgColor = runCatching { Color.parseColor(bg) }.getOrNull() ?: return
|
||||
val onBgColor = runCatching { Color.parseColor(onBg) }.getOrNull() ?: return
|
||||
|
||||
views.setInt(bgViewId, "setColorFilter", bgColor)
|
||||
for (id in primaryTextIds) views.setTextColor(id, onBgColor)
|
||||
|
||||
val secondary = (onBgColor and 0x00FFFFFF) or (0xB3 shl 24)
|
||||
for (id in secondaryTextIds) views.setTextColor(id, secondary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="20dp" />
|
||||
<solid android:color="@color/widget_background" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="6dp" />
|
||||
<solid android:color="@color/widget_tile_background" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="10dp" />
|
||||
<solid android:color="#B3000000" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="14dp" />
|
||||
<solid android:color="@color/widget_tile_background" />
|
||||
</shape>
|
||||
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/widget_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:id="@+id/widget_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/widget_cover"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/widget_current_book_label"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/widget_cover_placeholder" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_percent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_margin="6dp"
|
||||
android:background="@drawable/widget_percent_badge"
|
||||
android:paddingStart="7dp"
|
||||
android:paddingTop="3dp"
|
||||
android:paddingEnd="7dp"
|
||||
android:paddingBottom="3dp"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_authors"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_empty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:padding="12dp"
|
||||
android:text="@string/widget_current_book_empty"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="13sp"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,146 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/stats_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">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stats_label"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:text="@string/widget_stats_label"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stat_books_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stat_books_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_stat_books"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stat_authors_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stat_authors_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_stat_authors"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stat_categories_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stat_categories_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_stat_categories"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stat_series_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textColor="@color/widget_text_primary"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stat_series_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_stat_series"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="widget_current_book_label">Aktuelles Buch</string>
|
||||
<string name="widget_current_book_description">Zeigt das Buch, das du gerade liest</string>
|
||||
<string name="widget_current_book_empty">Öffne ein Buch, um es hier zu sehen</string>
|
||||
|
||||
<string name="widget_stats_label">Bibliothek</string>
|
||||
<string name="widget_stats_description">Deine Bibliothek auf einen Blick</string>
|
||||
<string name="widget_stat_books">Bücher</string>
|
||||
<string name="widget_stat_authors">Autoren</string>
|
||||
<string name="widget_stat_categories">Kategorien</string>
|
||||
<string name="widget_stat_series">Serien</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="widget_background">#FF1A1C18</color>
|
||||
<color name="widget_tile_background">#FF2C2F27</color>
|
||||
<color name="widget_text_primary">#FFE3E3DB</color>
|
||||
<color name="widget_text_secondary">#FFC4C8BA</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="widget_background">#FFF7F7F4</color>
|
||||
<color name="widget_tile_background">#FFE7E7E1</color>
|
||||
<color name="widget_text_primary">#FF1A1C18</color>
|
||||
<color name="widget_text_secondary">#FF44483D</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="widget_current_book_label">Current book</string>
|
||||
<string name="widget_current_book_description">Shows the book you are currently reading</string>
|
||||
<string name="widget_current_book_empty">Open a book to see it here</string>
|
||||
|
||||
<string name="widget_stats_label">Library</string>
|
||||
<string name="widget_stats_description">Your library at a glance</string>
|
||||
<string name="widget_stat_books">Books</string>
|
||||
<string name="widget_stat_authors">Authors</string>
|
||||
<string name="widget_stat_categories">Categories</string>
|
||||
<string name="widget_stat_series">Series</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="110dp"
|
||||
android:minHeight="180dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="3"
|
||||
android:description="@string/widget_current_book_description"
|
||||
android:initialLayout="@layout/widget_current_book"
|
||||
android:previewLayout="@layout/widget_current_book"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
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="180dp"
|
||||
android:minHeight="110dp"
|
||||
android:targetCellWidth="3"
|
||||
android:targetCellHeight="2"
|
||||
android:description="@string/widget_stats_description"
|
||||
android:initialLayout="@layout/widget_library_stats"
|
||||
android:previewLayout="@layout/widget_library_stats"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="0"
|
||||
android:widgetCategory="home_screen" />
|
||||
@@ -12,6 +12,7 @@ import 'package:calibre_web_companion/core/services/webdav_sync_service.dart';
|
||||
import 'package:calibre_web_companion/core/services/download_manager.dart';
|
||||
import 'package:calibre_web_companion/core/services/app_log_service.dart';
|
||||
import 'package:calibre_web_companion/core/services/connectivity_service.dart';
|
||||
import 'package:calibre_web_companion/core/services/widget_service.dart';
|
||||
import 'package:calibre_web_companion/features/offline/cubit/connectivity_cubit.dart';
|
||||
import 'package:calibre_web_companion/features/offline/data/repositories/offline_library_repository.dart';
|
||||
import 'package:calibre_web_companion/features/offline/data/services/offline_backfill_service.dart';
|
||||
@@ -104,6 +105,14 @@ Future<void> init() async {
|
||||
() => ConnectivityService(apiService: getIt<ApiService>()),
|
||||
);
|
||||
|
||||
getIt.registerLazySingleton<WidgetService>(
|
||||
() => WidgetService(
|
||||
prefs: getIt<SharedPreferences>(),
|
||||
logger: getIt<Logger>(),
|
||||
offlineRepository: getIt<OfflineLibraryRepository>(),
|
||||
),
|
||||
);
|
||||
|
||||
getIt.registerLazySingleton<ConnectivityCubit>(
|
||||
() => ConnectivityCubit(service: getIt<ConnectivityService>()),
|
||||
);
|
||||
@@ -213,7 +222,10 @@ Future<void> init() async {
|
||||
|
||||
// BLoCs
|
||||
getIt.registerFactory<MeBloc>(
|
||||
() => MeBloc(repository: getIt<MeRepository>()),
|
||||
() => MeBloc(
|
||||
repository: getIt<MeRepository>(),
|
||||
widgetService: getIt<WidgetService>(),
|
||||
),
|
||||
);
|
||||
|
||||
//? Discover Feature
|
||||
@@ -306,7 +318,10 @@ Future<void> init() async {
|
||||
|
||||
// BLoCs
|
||||
getIt.registerFactory<SettingsBloc>(
|
||||
() => SettingsBloc(repository: getIt<SettingsRepository>()),
|
||||
() => SettingsBloc(
|
||||
repository: getIt<SettingsRepository>(),
|
||||
widgetService: getIt<WidgetService>(),
|
||||
),
|
||||
);
|
||||
|
||||
//? Download Service Feature
|
||||
@@ -366,6 +381,7 @@ Future<void> init() async {
|
||||
logger: logger,
|
||||
progressRepository: getIt<ReadingProgressRepository>(),
|
||||
downloadManager: getIt<DownloadManager>(),
|
||||
widgetService: getIt<WidgetService>(),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:home_widget/home_widget.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.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/image_cache_manager.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';
|
||||
|
||||
enum WidgetTapTarget { bookDetails, internalReader, externalReader, appOnly }
|
||||
|
||||
extension WidgetTapTargetX on WidgetTapTarget {
|
||||
String get key {
|
||||
switch (this) {
|
||||
case WidgetTapTarget.bookDetails:
|
||||
return 'book_details';
|
||||
case WidgetTapTarget.internalReader:
|
||||
return 'internal_reader';
|
||||
case WidgetTapTarget.externalReader:
|
||||
return 'external_reader';
|
||||
case WidgetTapTarget.appOnly:
|
||||
return 'app_only';
|
||||
}
|
||||
}
|
||||
|
||||
static WidgetTapTarget fromKey(String? key) {
|
||||
switch (key) {
|
||||
case 'internal_reader':
|
||||
return WidgetTapTarget.internalReader;
|
||||
case 'external_reader':
|
||||
return WidgetTapTarget.externalReader;
|
||||
case 'app_only':
|
||||
return WidgetTapTarget.appOnly;
|
||||
case 'book_details':
|
||||
default:
|
||||
return WidgetTapTarget.bookDetails;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WidgetService {
|
||||
final SharedPreferences prefs;
|
||||
final Logger logger;
|
||||
final OfflineLibraryRepository offlineRepository;
|
||||
|
||||
WidgetService({
|
||||
required this.prefs,
|
||||
required this.logger,
|
||||
required this.offlineRepository,
|
||||
});
|
||||
|
||||
static const String _currentBookProvider = 'CurrentBookWidgetProvider';
|
||||
static const String _statsProvider = 'LibraryStatsWidgetProvider';
|
||||
|
||||
static const String kTapTargetKey = 'widget_tap_target';
|
||||
static const String _kCurrentBookKey = 'widget_current_book';
|
||||
|
||||
bool get _supported => Platform.isAndroid;
|
||||
|
||||
WidgetTapTarget get tapTarget =>
|
||||
WidgetTapTargetX.fromKey(prefs.getString(kTapTargetKey));
|
||||
|
||||
Future<void> setTapTarget(WidgetTapTarget target) async {
|
||||
await prefs.setString(kTapTargetKey, target.key);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? get currentBookRaw {
|
||||
final raw = prefs.getString(_kCurrentBookKey);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
return decoded is Map<String, dynamic> ? decoded : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> recordCurrentBook({
|
||||
required String uuid,
|
||||
required int id,
|
||||
required String title,
|
||||
required String authors,
|
||||
String? coverUrl,
|
||||
String format = 'epub',
|
||||
double? progress,
|
||||
}) async {
|
||||
if (uuid.isEmpty) return;
|
||||
|
||||
double resolvedProgress = (progress ?? 0.0).clamp(0.0, 1.0);
|
||||
if (progress == null) {
|
||||
final existing = currentBookRaw;
|
||||
if (existing != null && existing['uuid'] == uuid) {
|
||||
resolvedProgress = ((existing['progress'] as num?)?.toDouble() ?? 0.0)
|
||||
.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
final record = <String, dynamic>{
|
||||
'uuid': uuid,
|
||||
'id': id,
|
||||
'title': title,
|
||||
'authors': authors,
|
||||
'coverUrl': coverUrl ?? '',
|
||||
'format': format,
|
||||
'progress': resolvedProgress,
|
||||
'savedAt': DateTime.now().millisecondsSinceEpoch,
|
||||
};
|
||||
await prefs.setString(_kCurrentBookKey, jsonEncode(record));
|
||||
await _pushCurrentBook(record, refreshCover: true);
|
||||
}
|
||||
|
||||
Future<void> updateProgress(String uuid, double progress) async {
|
||||
final raw = currentBookRaw;
|
||||
if (raw == null || raw['uuid'] != uuid) return;
|
||||
final clamped = progress.clamp(0.0, 1.0);
|
||||
raw['progress'] = clamped;
|
||||
await prefs.setString(_kCurrentBookKey, jsonEncode(raw));
|
||||
if (!_supported) return;
|
||||
try {
|
||||
await HomeWidget.saveWidgetData<String>(
|
||||
'cb_progress',
|
||||
(clamped * 100).round().toString(),
|
||||
);
|
||||
await HomeWidget.updateWidget(androidName: _currentBookProvider);
|
||||
} catch (e) {
|
||||
logger.w('Failed to update widget progress: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCurrentBook() async {
|
||||
await prefs.remove(_kCurrentBookKey);
|
||||
if (!_supported) return;
|
||||
try {
|
||||
await HomeWidget.saveWidgetData<String>('cb_uuid', '');
|
||||
await HomeWidget.updateWidget(androidName: _currentBookProvider);
|
||||
} catch (e) {
|
||||
logger.w('Failed to clear current book widget: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pushCurrentBook(
|
||||
Map<String, dynamic> record, {
|
||||
required bool refreshCover,
|
||||
}) async {
|
||||
if (!_supported) return;
|
||||
try {
|
||||
String coverPath = prefs.getString('widget_current_cover_path') ?? '';
|
||||
if (refreshCover) {
|
||||
final materialized = await _materializeCover(
|
||||
record['uuid'] as String,
|
||||
(record['id'] as num?)?.toInt() ?? 0,
|
||||
(record['coverUrl'] as String?) ?? '',
|
||||
);
|
||||
coverPath = materialized ?? '';
|
||||
await prefs.setString('widget_current_cover_path', coverPath);
|
||||
}
|
||||
|
||||
final progress = ((record['progress'] as num?)?.toDouble() ?? 0.0);
|
||||
await HomeWidget.saveWidgetData<String>(
|
||||
'cb_uuid',
|
||||
record['uuid'] as String,
|
||||
);
|
||||
await HomeWidget.saveWidgetData<String>(
|
||||
'cb_title',
|
||||
record['title'] as String? ?? '',
|
||||
);
|
||||
await HomeWidget.saveWidgetData<String>(
|
||||
'cb_authors',
|
||||
record['authors'] as String? ?? '',
|
||||
);
|
||||
await HomeWidget.saveWidgetData<String>('cb_cover', coverPath);
|
||||
await HomeWidget.saveWidgetData<String>(
|
||||
'cb_progress',
|
||||
(progress * 100).round().toString(),
|
||||
);
|
||||
await HomeWidget.updateWidget(androidName: _currentBookProvider);
|
||||
} catch (e) {
|
||||
logger.w('Failed to push current book widget: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pushStats({
|
||||
required int books,
|
||||
required int authors,
|
||||
required int categories,
|
||||
required int series,
|
||||
}) async {
|
||||
if (!_supported) return;
|
||||
try {
|
||||
await HomeWidget.saveWidgetData<String>('st_books', books.toString());
|
||||
await HomeWidget.saveWidgetData<String>('st_authors', authors.toString());
|
||||
await HomeWidget.saveWidgetData<String>(
|
||||
'st_categories',
|
||||
categories.toString(),
|
||||
);
|
||||
await HomeWidget.saveWidgetData<String>('st_series', series.toString());
|
||||
await HomeWidget.updateWidget(androidName: _statsProvider);
|
||||
} catch (e) {
|
||||
logger.w('Failed to push stats widget: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pushThemeColors() async {
|
||||
final seed = _resolveSeedColor();
|
||||
final light = ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
final dark = ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
|
||||
final palette = <String, Color>{
|
||||
'th_bg_light': light.secondaryContainer,
|
||||
'th_bg_dark': dark.secondaryContainer,
|
||||
'th_on_bg_light': light.onSecondaryContainer,
|
||||
'th_on_bg_dark': dark.onSecondaryContainer,
|
||||
'th_tile_light': light.primaryContainer,
|
||||
'th_tile_dark': dark.primaryContainer,
|
||||
'th_on_tile_light': light.onPrimaryContainer,
|
||||
'th_on_tile_dark': dark.onPrimaryContainer,
|
||||
'th_accent_light': light.primary,
|
||||
'th_accent_dark': dark.primary,
|
||||
'th_on_accent_light': light.onPrimary,
|
||||
'th_on_accent_dark': dark.onPrimary,
|
||||
};
|
||||
|
||||
for (final entry in palette.entries) {
|
||||
await prefs.setString(entry.key, _hex(entry.value));
|
||||
}
|
||||
|
||||
if (!_supported) return;
|
||||
try {
|
||||
for (final entry in palette.entries) {
|
||||
await HomeWidget.saveWidgetData<String>(entry.key, _hex(entry.value));
|
||||
}
|
||||
await HomeWidget.updateWidget(androidName: _currentBookProvider);
|
||||
await HomeWidget.updateWidget(androidName: _statsProvider);
|
||||
} catch (e) {
|
||||
logger.w('Failed to push widget theme colors: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Color _resolveSeedColor() {
|
||||
final key = prefs.getString('theme_color_key') ?? 'lightGreen';
|
||||
return PredefinedColors.predefinedColors[key] ?? Colors.lightGreen;
|
||||
}
|
||||
|
||||
String _hex(Color color) =>
|
||||
'#${color.toARGB32().toRadixString(16).padLeft(8, '0')}';
|
||||
|
||||
Stream<Uri?> get widgetClicks => HomeWidget.widgetClicked;
|
||||
|
||||
Future<Uri?> initialWidgetLaunch() =>
|
||||
HomeWidget.initiallyLaunchedFromHomeWidget();
|
||||
|
||||
Future<String?> _materializeCover(
|
||||
String uuid,
|
||||
int id,
|
||||
String coverUrl,
|
||||
) async {
|
||||
try {
|
||||
final offlinePath = offlineRepository.getBook(uuid)?.coverPath;
|
||||
if (offlinePath != null && offlinePath.isNotEmpty) {
|
||||
final file = File(offlinePath);
|
||||
if (await file.exists() && await file.length() > 0) {
|
||||
return _copyToWidgetDir(uuid, file);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('Widget offline cover lookup failed: $e');
|
||||
}
|
||||
|
||||
final url = _buildCoverImageUrl(id, coverUrl);
|
||||
if (url != null) {
|
||||
try {
|
||||
final cached = await CustomCacheManager().getSingleFile(url);
|
||||
if (await cached.exists() && await cached.length() > 0) {
|
||||
return _copyToWidgetDir(uuid, cached);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.w('Widget cover fetch failed for "$url": $e');
|
||||
}
|
||||
}
|
||||
|
||||
logger.w('No usable widget cover for $uuid (coverUrl="$coverUrl", id=$id)');
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _buildCoverImageUrl(int id, String coverUrl) {
|
||||
final baseUrl = ApiService().getBaseUrl();
|
||||
if (baseUrl.isEmpty) return null;
|
||||
|
||||
if (coverUrl.isNotEmpty) {
|
||||
var clean = coverUrl.split('/api/v1/opds/').last;
|
||||
if (clean.startsWith('/')) clean = clean.substring(1);
|
||||
return '$baseUrl/$clean';
|
||||
}
|
||||
if (id <= 0) return null;
|
||||
|
||||
final isCalibre = prefs.getString('server_type') == 'calibre';
|
||||
if (isCalibre) {
|
||||
final libraryId = prefs.getString('calibre_library_id');
|
||||
final segment =
|
||||
(libraryId != null && libraryId.isNotEmpty) ? '/$libraryId' : '';
|
||||
return '$baseUrl/get/cover/$id$segment';
|
||||
}
|
||||
return '$baseUrl/opds/cover/$id';
|
||||
}
|
||||
|
||||
Future<String?> _copyToWidgetDir(String uuid, File source) async {
|
||||
try {
|
||||
final supportDir = await getApplicationSupportDirectory();
|
||||
final widgetDir = Directory(p.join(supportDir.path, 'widget'));
|
||||
if (!await widgetDir.exists()) {
|
||||
await widgetDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final dest = File(
|
||||
p.join(widgetDir.path, 'cover_${uuid.hashCode.toUnsigned(32)}.png'),
|
||||
);
|
||||
await source.copy(dest.path);
|
||||
return dest.path;
|
||||
} catch (e) {
|
||||
logger.w('Failed to copy widget cover: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import 'package:calibre_web_companion/features/book_details/bloc/book_details_ev
|
||||
import 'package:calibre_web_companion/features/book_details/bloc/book_details_state.dart';
|
||||
|
||||
import 'package:calibre_web_companion/core/services/download_manager.dart';
|
||||
import 'package:calibre_web_companion/core/services/widget_service.dart';
|
||||
|
||||
import 'package:calibre_web_companion/features/book_view/data/models/book_view_model.dart';
|
||||
import 'package:calibre_web_companion/core/exceptions/cancellation_exception.dart';
|
||||
@@ -23,12 +24,14 @@ class BookDetailsBloc extends Bloc<BookDetailsEvent, BookDetailsState> {
|
||||
final BookDetailsRepository repository;
|
||||
final ReadingProgressRepository progressRepository;
|
||||
final DownloadManager downloadManager;
|
||||
final WidgetService widgetService;
|
||||
final Logger logger;
|
||||
|
||||
BookDetailsBloc({
|
||||
required this.repository,
|
||||
required this.progressRepository,
|
||||
required this.downloadManager,
|
||||
required this.widgetService,
|
||||
required this.logger,
|
||||
}) : super(const BookDetailsState()) {
|
||||
on<LoadBookDetails>(_onLoadBookDetails);
|
||||
@@ -438,6 +441,7 @@ class BookDetailsBloc extends Bloc<BookDetailsEvent, BookDetailsState> {
|
||||
downloadProgress: 100,
|
||||
),
|
||||
);
|
||||
await _recordCurrentBookForWidget(format: format);
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -507,6 +511,7 @@ class BookDetailsBloc extends Bloc<BookDetailsEvent, BookDetailsState> {
|
||||
readerBytes: bytes,
|
||||
),
|
||||
);
|
||||
await _recordCurrentBookForWidget(format: event.format);
|
||||
} catch (e) {
|
||||
logger.e('Error opening book in internal reader: $e');
|
||||
emit(
|
||||
@@ -529,6 +534,7 @@ class BookDetailsBloc extends Bloc<BookDetailsEvent, BookDetailsState> {
|
||||
try {
|
||||
logger.i('Opening book in browser: ${state.bookDetails!.title}');
|
||||
await repository.openInBrowser(state.bookDetails!);
|
||||
await _recordCurrentBookForWidget();
|
||||
} catch (e) {
|
||||
logger.e('Error opening book in browser: $e');
|
||||
}
|
||||
@@ -849,4 +855,32 @@ class BookDetailsBloc extends Bloc<BookDetailsEvent, BookDetailsState> {
|
||||
) async {
|
||||
progressRepository.saveProgress(event.bookUuid, event.locatorJson);
|
||||
}
|
||||
|
||||
Future<void> _recordCurrentBookForWidget({String? format}) async {
|
||||
final details = state.bookDetails;
|
||||
final vm = state.bookViewModel;
|
||||
final uuid = vm?.uuid ?? details?.uuid ?? '';
|
||||
if (uuid.isEmpty) return;
|
||||
|
||||
final formats = details?.formats ?? vm?.formats ?? const <String>[];
|
||||
final resolvedFormat =
|
||||
format ?? (formats.isNotEmpty ? formats.first.toLowerCase() : 'epub');
|
||||
|
||||
final detailsCover = details?.coverUrl ?? '';
|
||||
final coverUrl =
|
||||
detailsCover.isNotEmpty ? detailsCover : (vm?.coverUrl ?? '');
|
||||
|
||||
try {
|
||||
await widgetService.recordCurrentBook(
|
||||
uuid: uuid,
|
||||
id: vm?.id ?? details?.id ?? 0,
|
||||
title: details?.title ?? vm?.title ?? '',
|
||||
authors: details?.authors ?? vm?.authors ?? '',
|
||||
coverUrl: coverUrl,
|
||||
format: resolvedFormat,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.w('Failed to record current book for widget: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import 'package:calibre_web_companion/features/book_details/bloc/book_details_st
|
||||
import 'package:calibre_web_companion/core/di/injection_container.dart';
|
||||
import 'package:calibre_web_companion/core/services/app_transition.dart';
|
||||
import 'package:calibre_web_companion/core/services/server_capabilities.dart';
|
||||
import 'package:calibre_web_companion/core/services/widget_service.dart';
|
||||
import 'package:calibre_web_companion/core/services/snackbar.dart';
|
||||
import 'package:calibre_web_companion/features/book_details/data/models/tag_model.dart';
|
||||
import 'package:calibre_web_companion/features/book_details/presentation/widgets/add_to_shelf_widget.dart';
|
||||
@@ -38,19 +39,22 @@ import 'package:calibre_web_companion/features/book_details/data/models/book_det
|
||||
import 'package:calibre_web_companion/shared/widgets/book_cover_widget.dart';
|
||||
import 'package:calibre_web_companion/l10n/app_localizations.dart';
|
||||
import 'package:cosmos_epub/cosmos_epub.dart';
|
||||
// Exposes cosmos_epub's `bookProgress` singleton for cross-device WebDAV sync.
|
||||
import 'package:cosmos_epub/show_epub.dart' as cosmos_reader;
|
||||
import 'package:calibre_web_companion/shared/widgets/app_dialog_button.dart';
|
||||
import 'package:calibre_web_companion/core/services/webdav_sync_service.dart';
|
||||
|
||||
enum BookAutoOpen { none, internalReader, externalReader }
|
||||
|
||||
class BookDetailsPage extends StatefulWidget {
|
||||
final BookViewModel bookViewModel;
|
||||
final String bookUuid;
|
||||
final BookAutoOpen autoOpenAction;
|
||||
|
||||
const BookDetailsPage({
|
||||
super.key,
|
||||
required this.bookViewModel,
|
||||
required this.bookUuid,
|
||||
this.autoOpenAction = BookAutoOpen.none,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -59,6 +63,8 @@ class BookDetailsPage extends StatefulWidget {
|
||||
|
||||
class _BookDetailsPageState extends State<BookDetailsPage> {
|
||||
bool _didUpdateMetadata = false;
|
||||
bool _didAutoOpen = false;
|
||||
int _lastWidgetPercent = -1;
|
||||
late final WebDavSyncService _webDavService;
|
||||
Timer? _readerProgressTimer;
|
||||
|
||||
@@ -226,6 +232,7 @@ class _BookDetailsPageState extends State<BookDetailsPage> {
|
||||
accentColor: Theme.of(context).colorScheme.primary,
|
||||
onPageFlip: (currentPage, totalPages) {
|
||||
_scheduleReaderProgressSync(bookUuid);
|
||||
_pushReadingProgressToWidget(bookUuid, currentPage, totalPages);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -238,7 +245,6 @@ class _BookDetailsPageState extends State<BookDetailsPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Flush any pending debounced upload immediately when the reader closes.
|
||||
_readerProgressTimer?.cancel();
|
||||
await _saveReaderProgressToCloud(bookUuid);
|
||||
}
|
||||
@@ -251,6 +257,93 @@ class _BookDetailsPageState extends State<BookDetailsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _pushReadingProgressToWidget(
|
||||
String bookUuid,
|
||||
int currentPage,
|
||||
int totalPages,
|
||||
) {
|
||||
if (totalPages <= 0) return;
|
||||
final percent = (((currentPage + 1) / totalPages) * 100).round().clamp(
|
||||
0,
|
||||
100,
|
||||
);
|
||||
if (percent == _lastWidgetPercent) return;
|
||||
_lastWidgetPercent = percent;
|
||||
getIt<WidgetService>().updateProgress(bookUuid, percent / 100);
|
||||
}
|
||||
|
||||
Future<void> _runAutoOpen(
|
||||
BuildContext context,
|
||||
BookDetailsState state,
|
||||
AppLocalizations localizations,
|
||||
) async {
|
||||
final details = state.bookDetails;
|
||||
if (details == null) return;
|
||||
|
||||
switch (widget.autoOpenAction) {
|
||||
case BookAutoOpen.none:
|
||||
return;
|
||||
case BookAutoOpen.internalReader:
|
||||
final format = await _selectInternalReaderFormat(
|
||||
context,
|
||||
localizations,
|
||||
details,
|
||||
);
|
||||
if (format == null || !context.mounted) return;
|
||||
context.read<BookDetailsBloc>().add(
|
||||
OpenBookInInternalReader(book: details, format: format),
|
||||
);
|
||||
return;
|
||||
case BookAutoOpen.externalReader:
|
||||
await _triggerExternalReader(context, localizations);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _triggerExternalReader(
|
||||
BuildContext context,
|
||||
AppLocalizations localizations,
|
||||
) async {
|
||||
final settingsState = context.read<SettingsBloc>().state;
|
||||
DocumentFile? selectedDirectory;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
if (settingsState.defaultDownloadPath.isEmpty) {
|
||||
selectedDirectory = await DocMan.pick.directory();
|
||||
if (selectedDirectory == null) {
|
||||
if (context.mounted) {
|
||||
context.showSnackBar(
|
||||
localizations.noFolderWasSelected,
|
||||
isError: true,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
final uri = settingsState.defaultDownloadPath;
|
||||
selectedDirectory =
|
||||
uri.isNotEmpty ? await DocumentFile.fromUri(uri) : null;
|
||||
if (selectedDirectory == null || !selectedDirectory.isDirectory) {
|
||||
if (context.mounted) {
|
||||
context.showSnackBar(
|
||||
localizations.noFolderWasSelected,
|
||||
isError: true,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
context.read<BookDetailsBloc>().add(
|
||||
OpenBookInReader(
|
||||
selectedDirectory: selectedDirectory,
|
||||
schema: settingsState.downloadSchema,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _restoreReaderProgressFromCloud(String bookUuid) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (!(prefs.getBool('webdav_enabled') ?? false)) return;
|
||||
@@ -343,6 +436,14 @@ class _BookDetailsPageState extends State<BookDetailsPage> {
|
||||
previous.seriesNavigationStatus !=
|
||||
current.seriesNavigationStatus,
|
||||
listener: (context, state) {
|
||||
if (!_didAutoOpen &&
|
||||
widget.autoOpenAction != BookAutoOpen.none &&
|
||||
state.status == BookDetailsStatus.loaded &&
|
||||
state.bookDetails != null) {
|
||||
_didAutoOpen = true;
|
||||
_runAutoOpen(context, state, localizations);
|
||||
}
|
||||
|
||||
if (state.readStatusState == ReadStatusState.success) {
|
||||
_didUpdateMetadata = true;
|
||||
context.showSnackBar(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'package:calibre_web_companion/core/services/widget_service.dart';
|
||||
import 'package:calibre_web_companion/features/me/bloc/me_event.dart';
|
||||
import 'package:calibre_web_companion/features/me/bloc/me_state.dart';
|
||||
|
||||
@@ -7,8 +8,10 @@ import 'package:calibre_web_companion/features/me/data/repositories/me_repositor
|
||||
|
||||
class MeBloc extends Bloc<MeEvent, MeState> {
|
||||
final MeRepository repository;
|
||||
final WidgetService widgetService;
|
||||
|
||||
MeBloc({required this.repository}) : super(const MeState()) {
|
||||
MeBloc({required this.repository, required this.widgetService})
|
||||
: super(const MeState()) {
|
||||
on<LoadStats>(_onLoadStats);
|
||||
on<LogOut>(_onLogOut);
|
||||
}
|
||||
@@ -30,6 +33,15 @@ class MeBloc extends Bloc<MeEvent, MeState> {
|
||||
showStats: showStats,
|
||||
),
|
||||
);
|
||||
|
||||
if (showStats) {
|
||||
await widgetService.pushStats(
|
||||
books: stats.books,
|
||||
authors: stats.authors,
|
||||
categories: stats.categories,
|
||||
series: stats.series,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
emit(state.copyWith(status: MeStatus.error, errorMessage: e.toString()));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'package:calibre_web_companion/core/services/widget_service.dart';
|
||||
import 'package:calibre_web_companion/features/settings/bloc/settings_event.dart';
|
||||
import 'package:calibre_web_companion/features/settings/bloc/settings_state.dart';
|
||||
|
||||
@@ -11,8 +12,10 @@ import 'package:calibre_web_companion/features/settings/data/repositories/settin
|
||||
|
||||
class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
|
||||
final SettingsRepository repository;
|
||||
final WidgetService widgetService;
|
||||
|
||||
SettingsBloc({required this.repository}) : super(const SettingsState()) {
|
||||
SettingsBloc({required this.repository, required this.widgetService})
|
||||
: super(const SettingsState()) {
|
||||
on<LoadSettings>(_onLoadSettings);
|
||||
on<SetThemeMode>(_onSetThemeMode);
|
||||
on<SetThemeSource>(_onSetThemeSource);
|
||||
@@ -106,6 +109,9 @@ class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
|
||||
enabledCategoryItems: settings.enabledCategoryItems,
|
||||
),
|
||||
);
|
||||
|
||||
// Keep the home-screen widgets themed to match the app.
|
||||
await widgetService.pushThemeColors();
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -140,6 +146,7 @@ class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
|
||||
try {
|
||||
await repository.setThemeSource(event.themeSource);
|
||||
emit(state.copyWith(themeSource: event.themeSource));
|
||||
await widgetService.pushThemeColors();
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -157,6 +164,7 @@ class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
|
||||
try {
|
||||
await repository.setSelectedColor(event.colorKey);
|
||||
emit(state.copyWith(selectedColorKey: event.colorKey));
|
||||
await widgetService.pushThemeColors();
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'package:calibre_web_companion/features/settings/presentation/widgets/fee
|
||||
import 'package:calibre_web_companion/features/settings/presentation/widgets/theme_selector_widget.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/features/settings/presentation/pages/widget_settings_page.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/settings/presentation/widgets/reachable_url_field.dart';
|
||||
@@ -165,6 +166,19 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
onTap:
|
||||
() => _openBookDetailsSettingsSubPage(context),
|
||||
),
|
||||
_buildSettingsCategoryNavCard(
|
||||
context,
|
||||
title: localizations.homeWidget,
|
||||
subtitle: localizations.homeWidgetSubtitle,
|
||||
icon: Icons.widgets_rounded,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
AppTransitions.createSlideRoute(
|
||||
const WidgetSettingsPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionTitle(context, localizations.feedback),
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:calibre_web_companion/core/di/injection_container.dart';
|
||||
import 'package:calibre_web_companion/core/services/widget_service.dart';
|
||||
import 'package:calibre_web_companion/l10n/app_localizations.dart';
|
||||
|
||||
class WidgetSettingsPage extends StatefulWidget {
|
||||
const WidgetSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
State<WidgetSettingsPage> createState() => _WidgetSettingsPageState();
|
||||
}
|
||||
|
||||
class _WidgetSettingsPageState extends State<WidgetSettingsPage> {
|
||||
final WidgetService _widgetService = getIt<WidgetService>();
|
||||
late WidgetTapTarget _tapTarget = _widgetService.tapTarget;
|
||||
|
||||
Future<void> _select(WidgetTapTarget target) async {
|
||||
if (target == _tapTarget) return;
|
||||
setState(() => _tapTarget = target);
|
||||
await _widgetService.setTapTarget(target);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localizations = AppLocalizations.of(context)!;
|
||||
|
||||
final options = <(WidgetTapTarget, String, IconData)>[
|
||||
(
|
||||
WidgetTapTarget.bookDetails,
|
||||
localizations.widgetActionBookDetails,
|
||||
Icons.menu_book_rounded,
|
||||
),
|
||||
(
|
||||
WidgetTapTarget.internalReader,
|
||||
localizations.widgetActionInternalReader,
|
||||
Icons.chrome_reader_mode_rounded,
|
||||
),
|
||||
(
|
||||
WidgetTapTarget.externalReader,
|
||||
localizations.widgetActionExternalReader,
|
||||
Icons.open_in_new_rounded,
|
||||
),
|
||||
(
|
||||
WidgetTapTarget.appOnly,
|
||||
localizations.widgetActionOpenApp,
|
||||
Icons.apps_rounded,
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(localizations.homeWidget)),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle(context, localizations.widgetTapAction),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(
|
||||
localizations.widgetTapActionDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
elevation: 3,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final option in options)
|
||||
ListTile(
|
||||
onTap: () => _select(option.$1),
|
||||
leading: Icon(
|
||||
option.$3,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
title: Text(option.$2),
|
||||
trailing:
|
||||
_tapTarget == option.$1
|
||||
? Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
)
|
||||
: const Icon(Icons.circle_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHowToCard(context, localizations),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHowToCard(BuildContext context, AppLocalizations localizations) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
elevation: 0,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.widgets_rounded,
|
||||
size: 28,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
localizations.widgetHowToAddTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
localizations.widgetHowToAddDescription,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(BuildContext context, String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -522,5 +522,15 @@
|
||||
"inMagicShelves": "In Magic Shelves",
|
||||
"magicRulesLoadFailed": "Aktuelle Regeln konnten nicht geladen werden (Server-Timeout). Du kannst sie neu erstellen und speichern.",
|
||||
"tapToChangeIcon": "Zum Ändern tippen",
|
||||
"change": "Ändern"
|
||||
"change": "Ändern",
|
||||
"homeWidget": "Startbildschirm-Widget",
|
||||
"homeWidgetSubtitle": "Aktuelles Buch & Bibliotheksstatistik",
|
||||
"widgetTapAction": "Beim Antippen des Widgets",
|
||||
"widgetTapActionDescription": "Wähle, was sich öffnet, wenn du das aktuelle Buch auf dem Startbildschirm antippst.",
|
||||
"widgetActionBookDetails": "Buchdetails öffnen",
|
||||
"widgetActionInternalReader": "Im integrierten Reader öffnen",
|
||||
"widgetActionExternalReader": "In externem Reader öffnen",
|
||||
"widgetActionOpenApp": "Nur die App öffnen",
|
||||
"widgetHowToAddTitle": "Widget hinzufügen",
|
||||
"widgetHowToAddDescription": "Halte den Startbildschirm gedrückt, wähle „Widgets“ und dann Calibre Web Companion."
|
||||
}
|
||||
|
||||
+11
-1
@@ -526,5 +526,15 @@
|
||||
"inMagicShelves": "In Magic Shelves",
|
||||
"magicRulesLoadFailed": "Couldn't load the current rules (server timed out). You can rebuild them and save.",
|
||||
"tapToChangeIcon": "Tap to change",
|
||||
"change": "Change"
|
||||
"change": "Change",
|
||||
"homeWidget": "Home screen widget",
|
||||
"homeWidgetSubtitle": "Current book & library stats",
|
||||
"widgetTapAction": "When tapping the widget",
|
||||
"widgetTapActionDescription": "Choose what opens when you tap the current book on your home screen.",
|
||||
"widgetActionBookDetails": "Open book details",
|
||||
"widgetActionInternalReader": "Open in built-in reader",
|
||||
"widgetActionExternalReader": "Open in external reader",
|
||||
"widgetActionOpenApp": "Just open the app",
|
||||
"widgetHowToAddTitle": "Add a widget",
|
||||
"widgetHowToAddDescription": "Long-press your home screen, choose Widgets, then pick Calibre Web Companion."
|
||||
}
|
||||
|
||||
@@ -14,10 +14,14 @@ import 'package:cosmos_epub/cosmos_epub.dart';
|
||||
import 'package:calibre_web_companion/l10n/app_localizations.dart';
|
||||
import 'package:calibre_web_companion/core/di/injection_container.dart' as di;
|
||||
import 'package:calibre_web_companion/core/services/api_service.dart';
|
||||
import 'package:calibre_web_companion/core/services/app_transition.dart';
|
||||
import 'package:calibre_web_companion/core/services/connectivity_service.dart';
|
||||
import 'package:calibre_web_companion/core/services/widget_service.dart';
|
||||
import 'package:calibre_web_companion/core/services/download_manager.dart';
|
||||
import 'package:calibre_web_companion/core/services/app_log_service.dart';
|
||||
import 'package:calibre_web_companion/features/book_details/bloc/book_details_bloc.dart';
|
||||
import 'package:calibre_web_companion/features/book_details/presentation/pages/book_details_page.dart';
|
||||
import 'package:calibre_web_companion/features/book_view/data/models/book_view_model.dart';
|
||||
import 'package:calibre_web_companion/features/login/data/datasources/login_remote_datasource.dart';
|
||||
import 'package:calibre_web_companion/features/settings/bloc/settings_state.dart';
|
||||
import 'package:calibre_web_companion/features/book_view/bloc/book_view_bloc.dart';
|
||||
@@ -158,12 +162,81 @@ class MyApp extends StatefulWidget {
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
late final Future<bool> _loginFuture = _isLoggedIn();
|
||||
StreamSubscription<Uri?>? _widgetClickSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupWidgetLaunch();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_widgetClickSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
void _setupWidgetLaunch() {
|
||||
final widgetService = getIt<WidgetService>();
|
||||
_widgetClickSub = widgetService.widgetClicks.listen(_handleWidgetLaunch);
|
||||
widgetService.initialWidgetLaunch().then(_handleWidgetLaunch);
|
||||
}
|
||||
|
||||
Future<void> _handleWidgetLaunch(Uri? uri) async {
|
||||
if (uri == null || uri.scheme != 'calibrewebcompanion') return;
|
||||
|
||||
if (uri.pathSegments.contains('stats')) return;
|
||||
|
||||
final widgetService = getIt<WidgetService>();
|
||||
final target = widgetService.tapTarget;
|
||||
if (target == WidgetTapTarget.appOnly) return;
|
||||
|
||||
final raw = widgetService.currentBookRaw;
|
||||
if (raw == null) 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,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isLoggedIn() async {
|
||||
final prefs = getIt<SharedPreferences>();
|
||||
final baseUrl = prefs.getString('base_url');
|
||||
|
||||
@@ -515,6 +515,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
home_widget:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: home_widget
|
||||
sha256: fece84c7464099873c3ace38394ad7053509a4b0e36e57a1105a6aa31f47f23c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -70,6 +70,7 @@ dependencies:
|
||||
archive:
|
||||
emoji_picker_flutter:
|
||||
connectivity_plus:
|
||||
home_widget:
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
|
||||
Reference in New Issue
Block a user