Files
readest/apps/readest-app/src-tauri/gen/apple/ReadestWidget/ReadingWidgetViews.swift
T
Huang Xin 7da41a65ad feat(widget): add mobile home-screen reading widgets (#1602) (#4842)
Add a resizable home-screen widget on iOS and Android showing recent
in-progress books with cover, reading progress, and tap-to-open.

- One responsive widget: Android resizable 1x1 to 4x3 (one book per
  column, up to 3); iOS Small/Medium/Large families. Covers are cropped,
  rounded, with a percent badge and a progress bar (baked into the bitmap
  on Android, SwiftUI overlays on iOS).
- TTS controls (previous, play-pause, next) appear in 2+ row sizes when
  TTS is active, wired to the existing media session. Reading progress
  stays live during background TTS via a fraction computed from the baked
  offline locations.
- Publishes a snapshot plus downsized cover thumbnails to the iOS App
  Group and Android SharedPreferences through a new update_reading_widget
  native-bridge command; refresh is debounced and driven by library and
  progress changes, TTS, and app backgrounding.
- Tapping a cover opens readest://book/{hash}, switching the reader in
  place when one is already open.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 18:03:16 +02:00

123 lines
3.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import WidgetKit
import SwiftUI
extension View {
/// Adaptive widget card surface: paper-white in light mode, near-black in
/// dark mode. Uses containerBackground on iOS 17+ (required there) and a
/// plain background on the iOS 15/16 deployment floor.
@ViewBuilder
func widgetCardBackground() -> some View {
if #available(iOS 17.0, *) {
containerBackground(Color(.systemBackground), for: .widget)
} else {
background(Color(.systemBackground))
}
}
}
private func bookURL(_ hash: String) -> URL { URL(string: "readest://book/\(hash)")! }
// MARK: - Progress Bar (overlaid along the bottom of the cover)
private struct ProgressBar: View {
let percent: Int
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.35))
Capsule().fill(Color.accentColor)
.frame(width: geo.size.width * CGFloat(min(100, max(0, percent))) / 100)
}
}
.frame(height: 3)
}
}
// MARK: - Cover Cell (shared by all widget families)
// Center-cropped cover image fills its cell; a % badge sits top-right and a
// thin progress bar is overlaid along the bottom edge. Missing-cover fallback
// shows a tinted tile with the title centered.
private struct CoverCell: View {
let book: WidgetSnapshotBook
var body: some View {
ZStack(alignment: .topTrailing) {
// Cover + progress bar (clipped together to the rounded rect)
ZStack(alignment: .bottom) {
if let image = WidgetSnapshotStore.coverImage(for: book.hash) {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Color(.secondarySystemBackground)
Text(book.title)
.font(.caption2)
.lineLimit(3)
.multilineTextAlignment(.center)
.padding(6)
}
ProgressBar(percent: book.percent)
.padding(.horizontal, 6)
.padding(.bottom, 5)
}
.clipShape(RoundedRectangle(cornerRadius: 8))
// % badge outside the clipShape so it is never trimmed
Text("\(book.percent)%")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 5)
.padding(.vertical, 3)
.background(Color.black.opacity(0.6))
.clipShape(Capsule())
.padding(5)
}
}
}
// MARK: - Small Widget (single cover fills the entire widget)
struct SmallReadingView: View {
let snapshot: WidgetSnapshot
var body: some View {
if let book = snapshot.books.first {
CoverCell(book: book)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.widgetURL(bookURL(book.hash))
} else {
EmptyReadingView(title: snapshot.emptyTitle)
}
}
}
// MARK: - Medium/Large Widget (row of up to 3 covers, no header for a clean UI)
struct RowReadingView: View {
let snapshot: WidgetSnapshot
var body: some View {
if snapshot.books.isEmpty {
EmptyReadingView(title: snapshot.emptyTitle)
} else {
HStack(alignment: .top, spacing: 10) {
ForEach(snapshot.books.prefix(3)) { book in
Link(destination: bookURL(book.hash)) {
CoverCell(book: book)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity)
}
}
.padding(12)
}
}
}
// MARK: - Empty State
struct EmptyReadingView: View {
let title: String
var body: some View {
VStack(spacing: 8) {
Image(systemName: "books.vertical").font(.title2).foregroundColor(.secondary)
Text(title).font(.system(size: 12)).foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding(12).frame(maxWidth: .infinity, maxHeight: .infinity)
}
}