7da41a65ad
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>
123 lines
3.7 KiB
Swift
123 lines
3.7 KiB
Swift
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)
|
||
}
|
||
}
|