diff --git a/ios/Platform/Platform/Features/Reader/Models/ReaderModels.swift b/ios/Platform/Platform/Features/Reader/Models/ReaderModels.swift index 41d0399..84abd17 100644 --- a/ios/Platform/Platform/Features/Reader/Models/ReaderModels.swift +++ b/ios/Platform/Platform/Features/Reader/Models/ReaderModels.swift @@ -48,6 +48,24 @@ struct ReaderEntry: Codable, Identifiable, Hashable { fullContent ?? content ?? "" } + var thumbnailURL: URL? { + // Extract first from content + let html = content ?? fullContent ?? "" + guard let range = html.range(of: #"]+src=["\']([^"\']+)["\']"#, options: .regularExpression) else { + return nil + } + let match = String(html[range]) + guard let srcRange = match.range(of: #"src=["\']([^"\']+)["\']"#, options: .regularExpression) else { + return nil + } + var src = String(match[srcRange]) + src = src.replacingOccurrences(of: "src=\"", with: "") + .replacingOccurrences(of: "src='", with: "") + .replacingOccurrences(of: "\"", with: "") + .replacingOccurrences(of: "'", with: "") + return URL(string: src) + } + private static let isoFormatter: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] diff --git a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift index 4834c85..09e18a5 100644 --- a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift @@ -2,6 +2,7 @@ import SwiftUI struct EntryListView: View { @Bindable var vm: ReaderViewModel + var isCardView: Bool = true var body: some View { if vm.isLoading && vm.entries.isEmpty { @@ -16,30 +17,10 @@ struct EntryListView: View { ) } else { ScrollView { - LazyVStack(spacing: 0) { - ForEach(vm.entries) { entry in - NavigationLink(value: entry) { - EntryRowView(entry: entry, vm: vm) - } - .buttonStyle(.plain) - - Divider() - .padding(.leading, 16) - } - - // Infinite scroll trigger - if vm.isLoadingMore { - ProgressView() - .padding() - } else { - Color.clear - .frame(height: 1) - .onAppear { - Task { await vm.loadMore() } - } - } - - Spacer(minLength: 80) + if isCardView { + cardLayout + } else { + listLayout } } .refreshable { @@ -50,9 +31,170 @@ struct EntryListView: View { } } } + + // MARK: - Card Layout + + private var cardLayout: some View { + LazyVStack(spacing: 12) { + ForEach(vm.entries) { entry in + NavigationLink(value: entry) { + EntryCardView(entry: entry, vm: vm) + } + .buttonStyle(.plain) + } + + loadMoreTrigger + + Spacer(minLength: 80) + } + .padding(.horizontal) + .padding(.top, 4) + } + + // MARK: - List Layout + + private var listLayout: some View { + LazyVStack(spacing: 0) { + ForEach(vm.entries) { entry in + NavigationLink(value: entry) { + EntryRowView(entry: entry, vm: vm) + } + .buttonStyle(.plain) + + Divider() + .padding(.leading, 36) + } + + loadMoreTrigger + + Spacer(minLength: 80) + } + } + + private var loadMoreTrigger: some View { + Group { + if vm.isLoadingMore { + ProgressView() + .padding() + } else { + Color.clear + .frame(height: 1) + .onAppear { + Task { await vm.loadMore() } + } + } + } + } } -// MARK: - Entry Row +// MARK: - Card View + +struct EntryCardView: View { + let entry: ReaderEntry + let vm: ReaderViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Thumbnail + if let thumbURL = entry.thumbnailURL { + AsyncImage(url: thumbURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 180) + .clipped() + case .failure: + cardPlaceholder + case .empty: + ProgressView() + .frame(maxWidth: .infinity) + .frame(height: 180) + .background(Color.accentWarm.opacity(0.06)) + @unknown default: + cardPlaceholder + } + } + } else { + cardPlaceholder + } + + // Content + VStack(alignment: .leading, spacing: 8) { + // Feed + time + HStack(spacing: 6) { + if !entry.isRead { + Circle() + .fill(Color.accentWarm) + .frame(width: 6, height: 6) + } + + Text(entry.feedName) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.accentWarm) + .lineLimit(1) + + Spacer() + + if !entry.timeAgo.isEmpty { + Text(entry.timeAgo) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + } + + // Title + Text(entry.displayTitle) + .font(.subheadline.weight(entry.isRead ? .medium : .bold)) + .foregroundStyle(entry.isRead ? Color.textSecondary : Color.textPrimary) + .lineLimit(3) + + // Bottom row + HStack(spacing: 8) { + if let author = entry.author, !author.isEmpty { + Text(author) + .font(.caption) + .foregroundStyle(Color.textTertiary) + .lineLimit(1) + } + + Spacer() + + Label(entry.readingTimeText, systemImage: "clock") + .font(.caption) + .foregroundStyle(Color.textTertiary) + + if entry.starred { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(.orange) + } + } + } + .padding(14) + } + .background(Color.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.04), radius: 6, y: 2) + .contextMenu { + entryContextMenu(entry: entry, vm: vm) + } + } + + private var cardPlaceholder: some View { + HStack(spacing: 8) { + Image(systemName: "newspaper.fill") + .font(.title2) + .foregroundStyle(Color.accentWarm.opacity(0.3)) + } + .frame(maxWidth: .infinity) + .frame(height: 80) + .background(Color.accentWarm.opacity(0.06)) + } +} + +// MARK: - List Row View struct EntryRowView: View { let entry: ReaderEntry @@ -60,7 +202,6 @@ struct EntryRowView: View { var body: some View { HStack(alignment: .top, spacing: 12) { - // Unread indicator Circle() .fill(entry.isRead ? Color.clear : Color.accentWarm) .frame(width: 8, height: 8) @@ -105,7 +246,21 @@ struct EntryRowView: View { Spacer() - if entry.starred { + // Thumbnail mini + if let thumbURL = entry.thumbnailURL { + AsyncImage(url: thumbURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 8)) + default: + EmptyView() + } + } + } else if entry.starred { Image(systemName: "star.fill") .font(.caption) .foregroundStyle(.orange) @@ -116,31 +271,38 @@ struct EntryRowView: View { .padding(.vertical, 12) .background(Color.canvas) .contextMenu { - Button { - Task { await vm.toggleRead(entry) } - } label: { - Label( - entry.isRead ? "Mark Unread" : "Mark Read", - systemImage: entry.isRead ? "envelope.badge" : "envelope.open" - ) - } - - Button { - Task { await vm.toggleStar(entry) } - } label: { - Label( - entry.starred ? "Unstar" : "Star", - systemImage: entry.starred ? "star.slash" : "star" - ) - } - - if entry.url != nil { - Button { - Task { await vm.saveToBrain(entry) } - } label: { - Label("Save to Brain", systemImage: "brain") - } - } + entryContextMenu(entry: entry, vm: vm) + } + } +} + +// MARK: - Shared Context Menu + +@ViewBuilder +private func entryContextMenu(entry: ReaderEntry, vm: ReaderViewModel) -> some View { + Button { + Task { await vm.toggleRead(entry) } + } label: { + Label( + entry.isRead ? "Mark Unread" : "Mark Read", + systemImage: entry.isRead ? "envelope.badge" : "envelope.open" + ) + } + + Button { + Task { await vm.toggleStar(entry) } + } label: { + Label( + entry.starred ? "Unstar" : "Star", + systemImage: entry.starred ? "star.slash" : "star" + ) + } + + if entry.url != nil { + Button { + Task { await vm.saveToBrain(entry) } + } label: { + Label("Save to Brain", systemImage: "brain") } } } diff --git a/ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift b/ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift index 47f3131..ed0dcae 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ReaderTabView.swift @@ -5,6 +5,7 @@ struct ReaderTabView: View { @State private var selectedSubTab = 0 @State private var showFeedSheet = false @State private var showFeedManagement = false + @State private var isCardView = true var body: some View { NavigationStack { @@ -81,11 +82,21 @@ struct ReaderTabView: View { } // Entry list - EntryListView(vm: vm) + EntryListView(vm: vm, isCardView: isCardView) } .background(Color.canvas) .navigationBarHidden(true) .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isCardView.toggle() + } + } label: { + Image(systemName: isCardView ? "list.bullet" : "square.grid.2x2") + .foregroundStyle(Color.accentWarm) + } + } ToolbarItem(placement: .topBarTrailing) { Menu { Button {