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:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
111
iOS/RSSuper/UI/AddFeedView.swift
Normal file
111
iOS/RSSuper/UI/AddFeedView.swift
Normal 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()
|
||||
}
|
||||
91
iOS/RSSuper/UI/BookmarkView.swift
Normal file
91
iOS/RSSuper/UI/BookmarkView.swift
Normal 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()
|
||||
}
|
||||
123
iOS/RSSuper/UI/FeedDetailView.swift
Normal file
123
iOS/RSSuper/UI/FeedDetailView.swift
Normal 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"
|
||||
))
|
||||
}
|
||||
}
|
||||
127
iOS/RSSuper/UI/FeedListView.swift
Normal file
127
iOS/RSSuper/UI/FeedListView.swift
Normal 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
46
iOS/RSSuper/UI/README.md
Normal 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
|
||||
69
iOS/RSSuper/UI/SettingsView.swift
Normal file
69
iOS/RSSuper/UI/SettingsView.swift
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user