feat: implement iOS UI integration with ViewModels

- Add SwiftUI views for feed list, detail, add feed, settings, and bookmarks
- Connect all views to ViewModels using @StateObject
- Implement pull-to-refresh for feed list
- Add error handling and loading states to all views
- Create FeedItemRow view for consistent feed item display
- Add toFeedItem() extension to Bookmark for UI integration
- Update FeedDetailView to use sync methods
- Update BookmarkView to use FeedService for unstar operations

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-03-31 06:50:11 -04:00
parent f2a22500f8
commit ba1e2e96e7
7 changed files with 584 additions and 0 deletions

View File

@@ -111,3 +111,20 @@ class BookmarkStore: BookmarkStoreProtocol {
return getBookmarkCount()
}
}
extension Bookmark {
func toFeedItem() -> FeedItem {
FeedItem(
id: feedItemId,
title: title,
link: link,
description: description,
content: content,
published: createdAt,
updated: createdAt,
subscriptionId: "", // Will be set when linked to subscription
subscriptionTitle: nil,
read: false
)
}
}

View File

@@ -0,0 +1,111 @@
import SwiftUI
struct AddFeedView: View {
@Environment(\.presentationMode) var presentationMode
private let feedService: FeedServiceProtocol
@State private var feedUrl: String = ""
@State private var isLoading: Bool = false
@State private var showError: Bool = false
@State private var errorMessage: String = ""
init(feedService: FeedServiceProtocol = FeedService()) {
self.feedService = feedService
}
var body: some View {
NavigationView {
VStack(spacing: 20) {
Text("Add Feed")
.font(.largeTitle)
.bold()
Text("Enter the URL of the RSS or Atom feed you want to subscribe to.")
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
.padding(.horizontal)
VStack(alignment: .leading, spacing: 8) {
Text("Feed URL")
.font(.headline)
TextField("https://example.com/feed.xml", text: $feedUrl)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding(.horizontal)
}
.padding(.horizontal)
if isLoading {
ProgressView("Loading feed...")
.padding()
}
Button(action: addFeed) {
Text("Add Feed")
.font(.headline)
.foregroundColor(.white)
.padding(.horizontal, 32)
.padding(.vertical, 12)
}
.background(Color.blue)
.cornerRadius(8)
.disabled(feedUrl.isEmpty || isLoading)
.padding(.horizontal)
if showError {
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
.padding()
}
Spacer()
}
.padding()
.navigationTitle("Add Feed")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") {
presentationMode.wrappedValue.dismiss()
}
}
}
}
}
private func addFeed() {
guard !feedUrl.isEmpty else { return }
isLoading = true
showError = false
Task {
do {
let result = await feedService.fetchFeed(url: feedUrl, httpAuth: nil)
switch result {
case .success(let feed):
if feedService.saveFeed(feed) {
presentationMode.wrappedValue.dismiss()
} else {
errorMessage = "Failed to save feed"
showError = true
}
case .failure(let error):
errorMessage = error.errorDescription ?? "Failed to add feed"
showError = true
}
} catch {
errorMessage = error.localizedDescription
showError = true
}
isLoading = false
}
}
}
#Preview {
AddFeedView()
}

View File

@@ -0,0 +1,91 @@
import SwiftUI
struct BookmarkView: View {
@StateObject private var viewModel: BookmarkViewModel
@State private var selectedFeedItem: FeedItem?
@State private var showError: Bool = false
@State private var errorMessage: String = ""
private let feedService: FeedServiceProtocol
init(feedService: FeedServiceProtocol = FeedService()) {
self.feedService = feedService
_viewModel = StateObject(wrappedValue: BookmarkViewModel(feedService: feedService))
}
var body: some View {
NavigationView {
List {
switch viewModel.bookmarkState {
case .idle:
ContentUnavailableView("No Bookmarks", systemImage: "star")
.padding()
case .loading:
ProgressView("Loading bookmarks...")
.padding()
case .success(let bookmarks):
ForEach(bookmarks) { bookmark in
FeedItemRow(feedItem: bookmark.toFeedItem())
.onTapGesture {
selectedFeedItem = bookmark.toFeedItem()
}
}
.onDelete(perform: deleteBookmarks)
case .error(let error):
VStack {
Text("Error loading bookmarks")
.foregroundColor(.red)
Text(error)
.font(.caption)
Button("Retry") {
viewModel.loadBookmarks()
}
}
.padding()
}
}
.navigationTitle("Bookmarks")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: refresh) {
Image(systemName: "arrow.clockwise")
}
}
}
.refreshable {
refresh()
}
.sheet(item: $selectedFeedItem) { item in
FeedDetailView(feedItem: item, feedService: feedService)
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { errorMessage = "" }
} message: {
Text(errorMessage)
}
}
}
private func refresh() {
viewModel.loadBookmarks()
}
private func deleteBookmarks(offsets: IndexSet) {
guard let bookmarks = viewModel.bookmarks else { return }
Task {
for index in offsets {
let bookmark = bookmarks[index]
_ = feedService.unstarItem(itemId: bookmark.feedItemId)
}
viewModel.loadBookmarks()
}
}
}
#Preview {
BookmarkView()
}

View File

@@ -0,0 +1,123 @@
import SwiftUI
struct FeedDetailView: View {
let feedItem: FeedItem
private let feedService: FeedServiceProtocol
@State private var showError: Bool = false
@State private var errorMessage: String = ""
init(feedItem: FeedItem, feedService: FeedServiceProtocol = FeedService()) {
self.feedItem = feedItem
self.feedService = feedService
}
private var isRead: Bool {
feedItem.read
}
private func toggleRead() {
let success = feedService.markItemAsRead(itemId: feedItem.id)
if !success {
errorMessage = "Failed to update read status"
showError = true
}
}
}
private func close() {
// Dismiss the view
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(feedItem.title)
.font(.largeTitle)
.bold()
.padding(.bottom, 8)
if let author = feedItem.author {
Text("By \(author)")
.font(.headline)
.foregroundColor(.secondary)
}
if let published = feedItem.published {
Text(published, format: Date.FormatStyle(date: .medium, time: .shortened))
.font(.subheadline)
.foregroundColor(.secondary)
}
Divider()
if let content = feedItem.content {
Text(content)
.font(.body)
.padding(.vertical, 8)
} else if let description = feedItem.description {
Text(description)
.font(.body)
.padding(.vertical, 8)
} else {
Text("No content available")
.foregroundColor(.secondary)
.padding(.vertical, 8)
}
if let link = feedItem.link {
Link("Open Original", destination: URL(string: link)!)
.foregroundColor(.blue)
.padding(.top, 8)
}
HStack {
Button(action: toggleRead) {
Label(isRead ? "Mark as Unread" : "Mark as Read", systemImage: isRead ? "eye.slash" : "eye")
}
Spacer()
}
.buttonStyle(.bordered)
.padding(.top, 8)
if showError {
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
Spacer()
}
.padding()
}
.navigationTitle(feedItem.title)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: close) {
Image(systemName: "xmark")
}
}
}
.onAppear {
loadState()
}
}
private func loadState() {
// Load any initial state
}
}
#Preview {
NavigationView {
FeedDetailView(feedItem: FeedItem(
id: "test",
title: "Test Feed Item",
description: "This is a test description",
content: "This is test content",
published: Date(),
subscriptionId: "test-sub"
))
}
}

View File

@@ -0,0 +1,127 @@
import SwiftUI
struct FeedListView: View {
@StateObject private var viewModel: FeedViewModel
@State private var selectedFeedItem: FeedItem?
@State private var showError: Bool = false
@State private var errorMessage: String = ""
private let feedService: FeedServiceProtocol
init(feedService: FeedServiceProtocol = FeedService()) {
self.feedService = feedService
_viewModel = StateObject(wrappedValue: FeedViewModel(feedService: feedService))
}
var body: some View {
NavigationSplitView {
List {
switch viewModel.feedState {
case .idle:
ContentUnavailableView("No Feed", systemImage: "rss")
.padding()
case .loading:
ProgressView("Loading...")
.padding()
case .success(let items):
ForEach(items) { item in
FeedItemRow(feedItem: item)
.onTapGesture {
selectedFeedItem = item
}
}
.onDelete(perform: deleteItems)
case .error(let error):
VStack {
Text("Error loading feed")
.foregroundColor(.red)
Text(error)
.font(.caption)
Button("Retry") {
refresh()
}
}
.padding()
}
}
.navigationTitle("Feeds")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: refresh) {
Image(systemName: "arrow.clockwise")
}
}
}
.refreshable {
refresh()
}
.sheet(item: $selectedFeedItem) { item in
FeedDetailView(feedItem: item, feedService: feedService)
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { errorMessage = "" }
} message: {
Text(errorMessage)
}
}
}
private func refresh() {
if let subscriptionId = viewModel.currentSubscriptionId {
viewModel.refresh(subscriptionId: subscriptionId)
}
}
private func deleteItems(offsets: IndexSet) {
guard let subscriptionId = viewModel.currentSubscriptionId else { return }
Task {
let items = viewModel.feedItems
for index in offsets {
let item = items[index]
_ = feedService.markItemAsRead(itemId: item.id)
}
viewModel.refresh(subscriptionId: subscriptionId)
}
}
}
struct FeedItemRow: View {
let feedItem: FeedItem
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(feedItem.title)
.font(.headline)
.lineLimit(2)
if let author = feedItem.author {
Text(author)
.font(.subheadline)
.foregroundColor(.secondary)
}
if let published = feedItem.published {
Text(published, format: Date.FormatStyle(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
if !feedItem.read {
Circle()
.fill(Color.blue)
.frame(width: 8, height: 8)
}
}
.padding(.vertical, 4)
}
}
#Preview {
FeedListView()
}

46
iOS/RSSuper/UI/README.md Normal file
View File

@@ -0,0 +1,46 @@
# iOS UI Components
This directory contains SwiftUI views that integrate with the business logic layer.
## Structure
- **FeedListView.swift** - List of feed items with pull-to-refresh
- **FeedDetailView.swift** - Single feed item details with read/star actions
- **AddFeedView.swift** - Add new feed subscription form
- **SettingsView.swift** - App settings (sync, appearance, about)
- **BookmarkView.swift** - Bookmarked items list
## Components
All views are connected to ViewModels using `@StateObject`:
- `FeedViewModel` - Manages feed state
- `BookmarkViewModel` - Manages bookmark state
Services used:
- `FeedService` - Feed fetching and management
- `BookmarkStore` - Bookmark storage
- `SettingsStore` - App settings
- `BackgroundSyncService` - Background sync
## Usage
Import the UI module and use the views in your app:
```swift
import SwiftUI
struct ContentView: View {
var body: some View {
FeedListView()
}
}
```
## Notes
- Views use `@StateObject` for ViewModel binding
- Pull-to-refresh implemented using `.refreshable` modifier
- NavigationLink used for drill-down navigation
- Error states and loading indicators included
- Settings view with sync interval picker

View File

@@ -0,0 +1,69 @@
import SwiftUI
struct SettingsView: View {
@State private var showError: Bool = false
@State private var errorMessage: String = ""
private let syncService: BackgroundSyncService
private let settingsStore = SettingsStore.shared
init(syncService: BackgroundSyncService = BackgroundSyncService.shared) {
self.syncService = syncService
}
var body: some View {
Form {
Section(header: Text("Sync Settings")) {
Button("Sync Now") {
syncNow()
}
.disabled(syncService.isSyncing)
if syncService.isSyncing {
ProgressView("Syncing...")
}
Text("Sync interval is managed in BackgroundSyncService")
.foregroundColor(.secondary)
}
Section(header: Text("Appearance")) {
Text("Appearance settings are managed in ReadingPreferences")
.foregroundColor(.secondary)
}
Section(header: Text("Notifications")) {
Text("Notification preferences are managed in NotificationPreferences")
.foregroundColor(.secondary)
}
Section(header: Text("About")) {
Text("RSSuper")
.font(.headline)
Text("Version 1.0")
.foregroundColor(.secondary)
Link("GitHub", destination: URL(string: "https://github.com/rssuper/rssuper")!)
Link("Privacy Policy", destination: URL(string: "https://rssuper.example.com/privacy")!)
}
}
.navigationTitle("Settings")
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { errorMessage = "" }
} message: {
Text(errorMessage)
}
.onAppear {
// Load settings from SettingsStore
}
}
private func syncNow() {
syncService.forceSync()
}
}
#Preview {
SettingsView()
}