Major improvements to offline reading functionality:
**Hero Image Offline Support:**
- Add heroImageURL field to BookmarkEntity for persistent storage
- Implement ImageCache-based caching with custom keys (bookmark-{id}-hero)
- Update CachedAsyncImage to support offline loading via cache keys
- Hero images now work offline without URL dependency
**Offline Bookmark Loading:**
- Add proactive offline detection before API calls
- Implement automatic fallback to cached bookmarks when offline
- Fix network status initialization race condition
- Network monitor now checks status synchronously on init
**Core Data Enhancements:**
- Persist hero image URLs in BookmarkEntity.heroImageURL
- Reconstruct ImageResource from cached URLs on offline load
- Add extensive logging for debugging persistence issues
**UI Updates:**
- Update BookmarkDetailView2 to use cache keys for hero images
- Update BookmarkCardView (all 3 layouts) with cache key support
- Improve BookmarksView offline state handling with task-based loading
- Add 50ms delay for network status propagation
**Technical Details:**
- NetworkMonitorRepository: Fix initial status from hardcoded true to actual network check
- BookmarksViewModel: Inject AppSettings for offline detection
- OfflineCacheRepository: Add verification logging for save/load operations
- BookmarkEntityMapper: Sync heroImageURL on save, restore on load
This enables full offline reading with hero images visible in bookmark lists
and detail views, even after app restart.
448 lines
17 KiB
Swift
448 lines
17 KiB
Swift
import SwiftUI
|
|
import Foundation
|
|
import SafariServices
|
|
|
|
extension View {
|
|
@ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
|
if condition {
|
|
transform(self)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
}
|
|
|
|
struct BookmarkCardView: View {
|
|
@Environment(\.colorScheme) var colorScheme
|
|
@EnvironmentObject var appSettings: AppSettings
|
|
|
|
let bookmark: Bookmark
|
|
let currentState: BookmarkState
|
|
let layout: CardLayoutStyle
|
|
let pendingDelete: PendingDelete?
|
|
let onArchive: (Bookmark) -> Void
|
|
let onDelete: (Bookmark) -> Void
|
|
let onToggleFavorite: (Bookmark) -> Void
|
|
let onUndoDelete: ((String) -> Void)?
|
|
|
|
init(
|
|
bookmark: Bookmark,
|
|
currentState: BookmarkState,
|
|
layout: CardLayoutStyle = .magazine,
|
|
pendingDelete: PendingDelete? = nil,
|
|
onArchive: @escaping (Bookmark) -> Void,
|
|
onDelete: @escaping (Bookmark) -> Void,
|
|
onToggleFavorite: @escaping (Bookmark) -> Void,
|
|
onUndoDelete: ((String) -> Void)? = nil
|
|
) {
|
|
self.bookmark = bookmark
|
|
self.currentState = currentState
|
|
self.layout = layout
|
|
self.pendingDelete = pendingDelete
|
|
self.onArchive = onArchive
|
|
self.onDelete = onDelete
|
|
self.onToggleFavorite = onToggleFavorite
|
|
self.onUndoDelete = onUndoDelete
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottom) {
|
|
Group {
|
|
switch layout {
|
|
case .compact:
|
|
compactLayoutView
|
|
case .magazine:
|
|
magazineLayoutView
|
|
case .natural:
|
|
naturalLayoutView
|
|
}
|
|
}
|
|
.opacity(pendingDelete != nil ? 0.4 : 1.0)
|
|
.animation(.easeInOut(duration: 0.2), value: pendingDelete != nil)
|
|
|
|
// Undo toast overlay with progress background
|
|
if let pendingDelete = pendingDelete {
|
|
VStack(spacing: 0) {
|
|
Spacer()
|
|
|
|
// Undo button area with circular progress
|
|
HStack {
|
|
HStack(spacing: 8) {
|
|
// Circular progress indicator
|
|
ZStack {
|
|
Circle()
|
|
.stroke(Color.gray.opacity(0.3), lineWidth: 2)
|
|
.frame(width: 16, height: 16)
|
|
Circle()
|
|
.trim(from: 0, to: CGFloat(pendingDelete.progress))
|
|
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
.frame(width: 16, height: 16)
|
|
.animation(.linear(duration: 0.1), value: pendingDelete.progress)
|
|
}
|
|
|
|
Text("Deleting...")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button("Undo") {
|
|
onUndoDelete?(bookmark.id)
|
|
}
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(.blue)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(Color.blue.opacity(0.1))
|
|
.clipShape(Capsule())
|
|
.onTapGesture {
|
|
onUndoDelete?(bookmark.id)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(Color(.systemBackground).opacity(0.95))
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: layout == .compact ? 8 : 12))
|
|
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
|
}
|
|
}
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
|
if pendingDelete == nil {
|
|
Button("Delete", role: .destructive) {
|
|
onDelete(bookmark)
|
|
}
|
|
.tint(.red)
|
|
}
|
|
}
|
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
|
if pendingDelete == nil {
|
|
Button {
|
|
onArchive(bookmark)
|
|
} label: {
|
|
if currentState == .archived {
|
|
Label("Restore", systemImage: "tray.and.arrow.up")
|
|
} else {
|
|
Label("Archive", systemImage: "archivebox")
|
|
}
|
|
}
|
|
.tint(currentState == .archived ? .blue : .orange)
|
|
|
|
Button {
|
|
onToggleFavorite(bookmark)
|
|
} label: {
|
|
Label(bookmark.isMarked ? "Remove" : "Favorite",
|
|
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
|
|
}
|
|
.tint(bookmark.isMarked ? .gray : .pink)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var compactLayoutView: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
CachedAsyncImage(
|
|
url: imageURL,
|
|
cacheKey: "bookmark-\(bookmark.id)-hero"
|
|
)
|
|
.aspectRatio(contentMode: .fill)
|
|
.frame(width: 80, height: 80)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(bookmark.title)
|
|
.font(.headline)
|
|
.fontWeight(.semibold)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.leading)
|
|
|
|
if !bookmark.description.isEmpty {
|
|
Text(bookmark.description)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.leading)
|
|
}
|
|
|
|
HStack(spacing: 4) {
|
|
if !bookmark.siteName.isEmpty {
|
|
HStack(spacing: 2) {
|
|
Image(systemName: "globe")
|
|
Text(bookmark.siteName)
|
|
}
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
|
HStack(spacing: 2) {
|
|
Image(systemName: "clock")
|
|
Text("\(readingTime) min")
|
|
}
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(Color(R.color.bookmark_list_bg))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
private var magazineLayoutView: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
CachedAsyncImage(
|
|
url: imageURL,
|
|
cacheKey: "bookmark-\(bookmark.id)-hero"
|
|
)
|
|
.aspectRatio(contentMode: .fill)
|
|
.frame(height: 140)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
|
|
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color(.systemBackground))
|
|
.frame(width: 36, height: 36)
|
|
Circle()
|
|
.stroke(Color.gray.opacity(0.2), lineWidth: 4)
|
|
.frame(width: 32, height: 32)
|
|
Circle()
|
|
.trim(from: 0, to: CGFloat(bookmark.readProgress) / 100)
|
|
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
.frame(width: 32, height: 32)
|
|
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
|
Text("\(bookmark.readProgress)")
|
|
.font(.caption2)
|
|
.bold()
|
|
Text("%")
|
|
.font(.system(size: 8))
|
|
.baselineOffset(2)
|
|
}
|
|
}
|
|
.padding(8)
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(bookmark.title)
|
|
.font(.headline)
|
|
.fontWeight(.semibold)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.leading)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
if let publishedDate = formattedPublishedDate {
|
|
HStack {
|
|
Label(publishedDate, systemImage: "calendar")
|
|
Spacer()
|
|
}
|
|
Spacer()
|
|
}
|
|
|
|
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
|
Label("\(readingTime) min", systemImage: "clock")
|
|
}
|
|
}
|
|
|
|
HStack {
|
|
if !bookmark.siteName.isEmpty {
|
|
Label(bookmark.siteName, systemImage: "globe")
|
|
}
|
|
}
|
|
HStack {
|
|
Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari")
|
|
.onTapGesture {
|
|
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
|
}
|
|
}
|
|
}
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.bottom, 12)
|
|
}
|
|
.background(Color(R.color.bookmark_list_bg))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4)
|
|
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
|
}
|
|
|
|
private var naturalLayoutView: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
CachedAsyncImage(
|
|
url: imageURL,
|
|
cacheKey: "bookmark-\(bookmark.id)-hero"
|
|
)
|
|
.aspectRatio(contentMode: .fill)
|
|
.frame(width: UIScreen.main.bounds.width - 32)
|
|
.clipped()
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
|
|
if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color(.systemBackground))
|
|
.frame(width: 36, height: 36)
|
|
Circle()
|
|
.stroke(Color.gray.opacity(0.2), lineWidth: 4)
|
|
.frame(width: 32, height: 32)
|
|
Circle()
|
|
.trim(from: 0, to: CGFloat(bookmark.readProgress) / 100)
|
|
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
.frame(width: 32, height: 32)
|
|
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
|
Text("\(bookmark.readProgress)")
|
|
.font(.caption2)
|
|
.bold()
|
|
Text("%")
|
|
.font(.system(size: 8))
|
|
.baselineOffset(2)
|
|
}
|
|
}
|
|
.padding(8)
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(bookmark.title)
|
|
.font(.headline)
|
|
.fontWeight(.semibold)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.leading)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
if let publishedDate = formattedPublishedDate {
|
|
HStack {
|
|
Label(publishedDate, systemImage: "calendar")
|
|
Spacer()
|
|
}
|
|
Spacer()
|
|
}
|
|
|
|
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
|
Label("\(readingTime) min", systemImage: "clock")
|
|
}
|
|
}
|
|
|
|
HStack {
|
|
if !bookmark.siteName.isEmpty {
|
|
Label(bookmark.siteName, systemImage: "globe")
|
|
}
|
|
}
|
|
HStack {
|
|
Label(URLUtil.openUrlLabel(for: bookmark.url), systemImage: "safari")
|
|
.onTapGesture {
|
|
URLUtil.open(url: bookmark.url, urlOpener: appSettings.urlOpener)
|
|
}
|
|
}
|
|
}
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.bottom, 12)
|
|
}
|
|
.background(Color(R.color.bookmark_list_bg))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3)
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
private var formattedPublishedDate: String? {
|
|
guard let published = bookmark.published, !published.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
if published.contains("1970-01-01") {
|
|
return nil
|
|
}
|
|
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"
|
|
|
|
guard let date = formatter.date(from: published) else {
|
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
|
|
guard let fallbackDate = formatter.date(from: published) else {
|
|
return nil
|
|
}
|
|
return formatDate(fallbackDate)
|
|
}
|
|
|
|
return formatDate(date)
|
|
}
|
|
|
|
private func formatDate(_ date: Date) -> String {
|
|
let calendar = Calendar.current
|
|
let now = Date()
|
|
|
|
// Today
|
|
if calendar.isDate(date, inSameDayAs: now) {
|
|
let formatter = DateFormatter()
|
|
formatter.timeStyle = .short
|
|
return "Today, \(formatter.string(from: date))"
|
|
}
|
|
|
|
// Yesterday
|
|
if let yesterday = calendar.date(byAdding: .day, value: -1, to: now),
|
|
calendar.isDate(date, inSameDayAs: yesterday) {
|
|
let formatter = DateFormatter()
|
|
formatter.timeStyle = .short
|
|
return "Yesterday, \(formatter.string(from: date))"
|
|
}
|
|
|
|
// This week
|
|
if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "EEEE, HH:mm"
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
// This year
|
|
if calendar.isDate(date, equalTo: now, toGranularity: .year) {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "d. MMM, HH:mm"
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
// Other years
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "d. MMM yyyy"
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
private var imageURL: URL? {
|
|
if let imageUrl = bookmark.resources.image?.src {
|
|
return URL(string: imageUrl)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
struct IconBadge: View {
|
|
let systemName: String
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
Image(systemName: systemName)
|
|
.frame(width: 20, height: 20)
|
|
.background(color)
|
|
.foregroundColor(.white)
|
|
.clipShape(Circle())
|
|
}
|
|
}
|