ReadKeep/readeck/UI/Bookmarks/BookmarkCardView.swift
Ilyas Hallak c13fc107b1 fix: Card width consistency and layout loading in search
- Fixed natural layout width using screen bounds instead of infinity
- Added card layout settings loading in SearchBookmarksView
- Consistent card width across all views prevents overflow
2025-09-05 21:58:24 +02:00

437 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
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)
.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)
.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.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
.onTapGesture {
SafariUtil.openInSafari(url: bookmark.url)
}
}
}
.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)
.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.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
.onTapGesture {
SafariUtil.openInSafari(url: bookmark.url)
}
}
}
.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())
}
}