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 {