feature: begon to work on the homescreen widgets

This commit is contained in:
Daniel
2026-07-07 10:47:35 +02:00
parent ff03d4eda6
commit 6d62d7cfaf
29 changed files with 1342 additions and 8 deletions
+25
View File
@@ -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
@@ -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
}
}
@@ -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" />
+18 -2
View File
@@ -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>(),
),
);
+336
View File
@@ -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(
+13 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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."
}
+73
View File
@@ -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');
+8
View File
@@ -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:
+1
View File
@@ -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.