From 5fd525e6d1d0cf25a4158c37b8db4e9ad0897ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=9C=E6=AA=B8Cirtron?= <45784494+lcandy2@users.noreply.github.com> Date: Thu, 9 Jan 2025 02:59:05 +0800 Subject: [PATCH 1/6] refactor: remove obsolete ShelfModels and introduce Status model - Deleted the ShelfModels.swift file, which contained unused shelf-related enums and structs. - Added a new Status model in Status.swift, encapsulating various properties for status updates, including visibility, media attachments, and account details. - Enhanced data handling capabilities with the introduction of new structures for Account, MediaAttachment, Mention, Tag, and Card, improving the overall architecture for timeline management. --- NeoDB/NeoDB/Models/Shelf/ShelfModels.swift | 99 ------------ .../Timeline => Services/Models}/Status.swift | 0 .../Services/Models/Timeline/Status.swift | 149 ++++++++++++++++++ 3 files changed, 149 insertions(+), 99 deletions(-) delete mode 100644 NeoDB/NeoDB/Models/Shelf/ShelfModels.swift rename NeoDB/NeoDB/{Models/Timeline => Services/Models}/Status.swift (100%) create mode 100644 NeoDB/NeoDB/Services/Models/Timeline/Status.swift diff --git a/NeoDB/NeoDB/Models/Shelf/ShelfModels.swift b/NeoDB/NeoDB/Models/Shelf/ShelfModels.swift deleted file mode 100644 index e731682..0000000 --- a/NeoDB/NeoDB/Models/Shelf/ShelfModels.swift +++ /dev/null @@ -1,99 +0,0 @@ -import Foundation - -enum ShelfType: String, Codable, CaseIterable { - case wishlist - case progress - case complete - case dropped - - var displayName: String { - switch self { - case .wishlist: return "Want to Read" - case .progress: return "Reading" - case .complete: return "Completed" - case .dropped: return "Dropped" - } - } - - var systemImage: String { - switch self { - case .wishlist: return "star" - case .progress: return "book" - case .complete: return "checkmark.circle" - case .dropped: return "xmark.circle" - } - } -} - -enum ItemCategory: String, Codable { - case book, movie, tv, music, game, podcast - case performance, fanfic, exhibition, collection -} - -struct PagedMarkSchema: Codable { - let data: [MarkSchema] - let pages: Int - let count: Int -} - -struct MarkSchema: Codable, Identifiable { - var id: String { item.uuid } - let shelfType: ShelfType - let visibility: Int - let item: ItemSchema - let createdTime: Date - let commentText: String? - let ratingGrade: Int? - let tags: [String] - - enum CodingKeys: String, CodingKey { - case shelfType = "shelf_type" - case visibility - case item - case createdTime = "created_time" - case commentText = "comment_text" - case ratingGrade = "rating_grade" - case tags - } -} - -struct ItemSchema: Codable { - let title: String - let description: String - let localizedTitle: [LocalizedTitle] - let localizedDescription: [LocalizedTitle] - let coverImageUrl: String? - let rating: Double? - let ratingCount: Int? - let id: String - let type: String - let uuid: String - let url: String - let apiUrl: String - let category: ItemCategory - let parentUuid: String? - let displayTitle: String - let externalResources: [ExternalResource]? - - enum CodingKeys: String, CodingKey { - case title, description, type, id, uuid, url, category - case localizedTitle = "localized_title" - case localizedDescription = "localized_description" - case coverImageUrl = "cover_image_url" - case ratingCount = "rating_count" - case apiUrl = "api_url" - case parentUuid = "parent_uuid" - case displayTitle = "display_title" - case externalResources = "external_resources" - case rating - } -} - -struct LocalizedTitle: Codable { - let lang: String - let text: String -} - -struct ExternalResource: Codable { - let url: String -} \ No newline at end of file diff --git a/NeoDB/NeoDB/Models/Timeline/Status.swift b/NeoDB/NeoDB/Services/Models/Status.swift similarity index 100% rename from NeoDB/NeoDB/Models/Timeline/Status.swift rename to NeoDB/NeoDB/Services/Models/Status.swift diff --git a/NeoDB/NeoDB/Services/Models/Timeline/Status.swift b/NeoDB/NeoDB/Services/Models/Timeline/Status.swift new file mode 100644 index 0000000..948cf0e --- /dev/null +++ b/NeoDB/NeoDB/Services/Models/Timeline/Status.swift @@ -0,0 +1,149 @@ +import Foundation + +class Status: Codable, Identifiable { + let id: String + let uri: String + let createdAt: Date + let editedAt: Date? + let content: String + let text: String + let visibility: Visibility + let sensitive: Bool + let spoilerText: String + let url: String + let account: Account + let mediaAttachments: [MediaAttachment] + let mentions: [Mention] + let tags: [Tag] + let card: Card? + let language: String? + let favourited: Bool + let reblogged: Bool + let muted: Bool + let bookmarked: Bool + let pinned: Bool + let reblog: Status? + let favouritesCount: Int + let reblogsCount: Int + let repliesCount: Int + + enum Visibility: String, Codable { + case `public` + case unlisted + case `private` + case direct + } + + enum CodingKeys: String, CodingKey { + case id, uri, content, text, visibility, sensitive, url, account, mentions, tags, card, language + case createdAt = "created_at" + case editedAt = "edited_at" + case spoilerText = "spoiler_text" + case mediaAttachments = "media_attachments" + case favourited, reblogged, muted, bookmarked, pinned, reblog + case favouritesCount = "favourites_count" + case reblogsCount = "reblogs_count" + case repliesCount = "replies_count" + } +} + +struct Account: Codable, Identifiable { + let id: String + let username: String + let acct: String + let url: String + let displayName: String + let note: String + let avatar: String + let avatarStatic: String + let header: String + let headerStatic: String + let locked: Bool + let bot: Bool + let group: Bool + let discoverable: Bool + let indexable: Bool + let statusesCount: Int + let followersCount: Int + let followingCount: Int + let createdAt: Date + let lastStatusAt: String? + let fields: [Field] + let emojis: [Emoji] + + enum CodingKeys: String, CodingKey { + case id, username, acct, url, note, avatar, locked, bot, group, fields, emojis + case displayName = "display_name" + case avatarStatic = "avatar_static" + case header, headerStatic = "header_static" + case discoverable, indexable + case statusesCount = "statuses_count" + case followersCount = "followers_count" + case followingCount = "following_count" + case createdAt = "created_at" + case lastStatusAt = "last_status_at" + } +} + +struct Field: Codable { + let name: String + let value: String + let verifiedAt: Date? + + enum CodingKeys: String, CodingKey { + case name, value + case verifiedAt = "verified_at" + } +} + +struct Emoji: Codable { + let shortcode: String + let url: String + let staticUrl: String + let visibleInPicker: Bool + + enum CodingKeys: String, CodingKey { + case shortcode, url + case staticUrl = "static_url" + case visibleInPicker = "visible_in_picker" + } +} + +struct MediaAttachment: Codable, Identifiable { + let id: String + let type: MediaType + let url: String + let previewUrl: String? + let description: String? + + enum MediaType: String, Codable { + case image + case video + case gifv + case audio + case unknown + } + + enum CodingKeys: String, CodingKey { + case id, type, url, description + case previewUrl = "preview_url" + } +} + +struct Mention: Codable { + let username: String + let url: String + let acct: String +} + +struct Tag: Codable { + let name: String + let url: String +} + +struct Card: Codable { + let url: String + let title: String + let description: String + let image: String? +} \ No newline at end of file From 2ebaa1358d54f9d12aa017732bf9af728c9da990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=9C=E6=AA=B8Cirtron?= <45784494+lcandy2@users.noreply.github.com> Date: Thu, 9 Jan 2025 02:59:26 +0800 Subject: [PATCH 2/6] feat: implement router for navigation and remove Status model - Introduced a Router object to manage navigation paths and sheet presentations across the app. - Updated ContentView to utilize NavigationStack with dynamic navigation destinations for various views (e.g., ItemDetail, UserProfile). - Enhanced NeoDBApp to provide the Router object to ContentView. - Removed the obsolete Status model, streamlining the codebase and improving maintainability. - Added TODO comments for future implementation of detailed views, ensuring clarity on upcoming features. --- NeoDB/NeoDB/ContentView.swift | 104 +++++++++- NeoDB/NeoDB/NeoDBApp.swift | 22 +- NeoDB/NeoDB/References/Cursor/router.md | 146 ++++++++++++++ NeoDB/NeoDB/References/IceCubes/Router.md | 121 +++++++++++ NeoDB/NeoDB/Services/Models/ShelfModels.swift | 99 +++++++++ NeoDB/NeoDB/Services/Models/Status.swift | 149 -------------- NeoDB/NeoDB/Services/Navigation/Router.swift | 189 ++++++++++++++++++ 7 files changed, 669 insertions(+), 161 deletions(-) create mode 100644 NeoDB/NeoDB/References/Cursor/router.md create mode 100644 NeoDB/NeoDB/References/IceCubes/Router.md create mode 100644 NeoDB/NeoDB/Services/Models/ShelfModels.swift delete mode 100644 NeoDB/NeoDB/Services/Models/Status.swift create mode 100644 NeoDB/NeoDB/Services/Navigation/Router.swift diff --git a/NeoDB/NeoDB/ContentView.swift b/NeoDB/NeoDB/ContentView.swift index 0d86afd..0c9d79e 100644 --- a/NeoDB/NeoDB/ContentView.swift +++ b/NeoDB/NeoDB/ContentView.swift @@ -9,11 +9,38 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var authService: AuthService + @StateObject private var router = Router() var body: some View { TabView { - NavigationStack { + NavigationStack(path: $router.path) { HomeView(authService: authService) + .navigationDestination(for: RouterDestination.self) { destination in + switch destination { + case .itemDetail(let id): + Text("Item Detail: \(id)") // TODO: Implement ItemDetailView + case .itemDetailWithItem(let item): + Text("Item Detail: \(item.displayTitle)") // TODO: Implement ItemDetailView + case .shelfDetail(let type): + Text("Shelf: \(type.displayName)") // TODO: Implement ShelfDetailView + case .userShelf(let userId, let type): + Text("User Shelf: \(userId) - \(type.displayName)") // TODO: Implement UserShelfView + case .userProfile(let id): + Text("User Profile: \(id)") // TODO: Implement UserProfileView + case .userProfileWithUser(let user): + Text("User Profile: \(user.displayName)") // TODO: Implement UserProfileView + case .statusDetail(let id): + Text("Status: \(id)") // TODO: Implement StatusDetailView + case .statusDetailWithStatus(let status): + Text("Status: \(status.id)") // TODO: Implement StatusDetailView + case .hashTag(let tag): + Text("Tag: #\(tag)") // TODO: Implement HashTagView + case .followers(let id): + Text("Followers: \(id)") // TODO: Implement FollowersView + case .following(let id): + Text("Following: \(id)") // TODO: Implement FollowingView + } + } } .tabItem { Label("Home", systemImage: "house.fill") @@ -27,26 +54,89 @@ struct ContentView: View { Label("Search", systemImage: "magnifyingglass") } - NavigationStack { + NavigationStack(path: $router.path) { LibraryView(authService: authService) + .navigationDestination(for: RouterDestination.self) { destination in + switch destination { + case .itemDetail(let id): + Text("Item Detail: \(id)") // TODO: Implement ItemDetailView + case .itemDetailWithItem(let item): + Text("Item Detail: \(item.displayTitle)") // TODO: Implement ItemDetailView + case .shelfDetail(let type): + Text("Shelf: \(type.displayName)") // TODO: Implement ShelfDetailView + case .userShelf(let userId, let type): + Text("User Shelf: \(userId) - \(type.displayName)") // TODO: Implement UserShelfView + case .userProfile(let id): + Text("User Profile: \(id)") // TODO: Implement UserProfileView + case .userProfileWithUser(let user): + Text("User Profile: \(user.displayName)") // TODO: Implement UserProfileView + case .statusDetail(let id): + Text("Status: \(id)") // TODO: Implement StatusDetailView + case .statusDetailWithStatus(let status): + Text("Status: \(status.id)") // TODO: Implement StatusDetailView + case .hashTag(let tag): + Text("Tag: #\(tag)") // TODO: Implement HashTagView + case .followers(let id): + Text("Followers: \(id)") // TODO: Implement FollowersView + case .following(let id): + Text("Following: \(id)") // TODO: Implement FollowingView + } + } } .tabItem { Label("Library", systemImage: "books.vertical.fill") } - NavigationStack { + NavigationStack(path: $router.path) { ProfileView(authService: authService) + .navigationDestination(for: RouterDestination.self) { destination in + switch destination { + case .itemDetail(let id): + Text("Item Detail: \(id)") // TODO: Implement ItemDetailView + case .itemDetailWithItem(let item): + Text("Item Detail: \(item.displayTitle)") // TODO: Implement ItemDetailView + case .shelfDetail(let type): + Text("Shelf: \(type.displayName)") // TODO: Implement ShelfDetailView + case .userShelf(let userId, let type): + Text("User Shelf: \(userId) - \(type.displayName)") // TODO: Implement UserShelfView + case .userProfile(let id): + Text("User Profile: \(id)") // TODO: Implement UserProfileView + case .userProfileWithUser(let user): + Text("User Profile: \(user.displayName)") // TODO: Implement UserProfileView + case .statusDetail(let id): + Text("Status: \(id)") // TODO: Implement StatusDetailView + case .statusDetailWithStatus(let status): + Text("Status: \(status.id)") // TODO: Implement StatusDetailView + case .hashTag(let tag): + Text("Tag: #\(tag)") // TODO: Implement HashTagView + case .followers(let id): + Text("Followers: \(id)") // TODO: Implement FollowersView + case .following(let id): + Text("Following: \(id)") // TODO: Implement FollowingView + } + } } .tabItem { Label("Profile", systemImage: "person.fill") } } .tint(.accentColor) + .environmentObject(router) + .sheet(item: $router.presentedSheet) { sheet in + switch sheet { + case .newStatus: + Text("New Status") // TODO: Implement StatusEditorView + case .editStatus(let status): + Text("Edit Status: \(status.id)") // TODO: Implement StatusEditorView + case .replyToStatus(let status): + Text("Reply to: \(status.id)") // TODO: Implement StatusEditorView + case .addToShelf(let item): + Text("Add to Shelf: \(item.displayTitle)") // TODO: Implement ShelfEditorView + case .editShelfItem(let mark): + Text("Edit Shelf Item: \(mark.item.displayTitle)") // TODO: Implement ShelfEditorView + } + } } - - #if DEBUG - @ObserveInjection var forceRedraw - #endif } #Preview { diff --git a/NeoDB/NeoDB/NeoDBApp.swift b/NeoDB/NeoDB/NeoDBApp.swift index 6be666a..717ae9e 100644 --- a/NeoDB/NeoDB/NeoDBApp.swift +++ b/NeoDB/NeoDB/NeoDBApp.swift @@ -10,6 +10,7 @@ import SwiftUI @main struct NeoDBApp: App { @StateObject private var authService = AuthService() + @StateObject private var router = Router() var body: some Scene { WindowGroup { @@ -17,18 +18,29 @@ struct NeoDBApp: App { if authService.isAuthenticated { ContentView() .environmentObject(authService) + .environmentObject(router) } else { LoginView() .environmentObject(authService) } } .onOpenURL { url in - Task { - do { - try await authService.handleCallback(url: url) - } catch { - print("Authentication error: \(error)") + // First try to handle OAuth callback + if url.scheme == "neodb" && url.host == "oauth" { + Task { + do { + try await authService.handleCallback(url: url) + } catch { + print("Authentication error: \(error)") + } } + return + } + + // Then try to handle deep links + if !router.handleURL(url) { + // If the router didn't handle the URL, open it in the default browser + UIApplication.shared.open(url) } } } diff --git a/NeoDB/NeoDB/References/Cursor/router.md b/NeoDB/NeoDB/References/Cursor/router.md new file mode 100644 index 0000000..f794ac5 --- /dev/null +++ b/NeoDB/NeoDB/References/Cursor/router.md @@ -0,0 +1,146 @@ +# Router Implementation Design + +## Overview +Implementing a navigation router system for NeoDB app, inspired by IceCubes' implementation. + +## Router Destinations +Main navigation destinations in the app: + +```swift +enum RouterDestination: Hashable { + // Library + case itemDetail(id: String) + case itemDetailWithItem(item: ItemSchema) + case shelfDetail(type: ShelfType) + case userShelf(userId: String, type: ShelfType) + + // Social + case userProfile(id: String) + case userProfileWithUser(user: User) + case statusDetail(id: String) + case statusDetailWithStatus(status: Status) + case hashTag(tag: String) + + // Lists + case followers(id: String) + case following(id: String) +} +``` + +## Sheet Destinations +Modal presentations in the app: + +```swift +enum SheetDestination: Identifiable { + case newStatus + case editStatus(status: Status) + case replyToStatus(status: Status) + case addToShelf(item: ItemSchema) + case editShelfItem(mark: MarkSchema) + + var id: String { + switch self { + case .newStatus, .editStatus, .replyToStatus: + return "statusEditor" + case .addToShelf: + return "shelfEditor" + case .editShelfItem: + return "shelfItemEditor" + } + } +} +``` + +## Router Implementation +Main router class that manages navigation state: + +```swift +@MainActor +class Router: ObservableObject { + @Published var path: [RouterDestination] = [] + @Published var presentedSheet: SheetDestination? + + func navigate(to destination: RouterDestination) { + path.append(destination) + } + + func dismissSheet() { + presentedSheet = nil + } + + func handleURL(_ url: URL) -> Bool { + // Handle deep links and internal navigation + if url.host == "neodb.social" { + if let id = url.lastPathComponent { + if url.path.contains("/items/") { + navigate(to: .itemDetail(id: id)) + return true + } else if url.path.contains("/users/") { + navigate(to: .userProfile(id: id)) + return true + } + } + } + return false + } +} +``` + +## Integration with SwiftUI +Example of integration in the main app structure: + +```swift +struct ContentView: View { + @StateObject private var router = Router() + + var body: some View { + TabView { + NavigationStack(path: $router.path) { + HomeView() + .navigationDestination(for: RouterDestination.self) { destination in + switch destination { + case .itemDetail(let id): + ItemDetailView(id: id) + case .userProfile(let id): + UserProfileView(id: id) + // ... other cases + } + } + } + // ... other tabs + } + .environmentObject(router) + .sheet(item: $router.presentedSheet) { sheet in + switch sheet { + case .newStatus: + StatusEditorView() + case .addToShelf(let item): + ShelfEditorView(item: item) + // ... other cases + } + } + } +} +``` + +## Benefits +1. Centralized navigation management +2. Type-safe routing with enums +3. Deep linking support +4. Consistent navigation patterns +5. Easy to extend and maintain + +## Usage Examples +```swift +// In a view +@EnvironmentObject private var router: Router + +// Navigate +router.navigate(to: .itemDetail(id: "123")) + +// Present sheet +router.presentedSheet = .addToShelf(item: someItem) + +// Handle URL +router.handleURL(someURL) +``` \ No newline at end of file diff --git a/NeoDB/NeoDB/References/IceCubes/Router.md b/NeoDB/NeoDB/References/IceCubes/Router.md new file mode 100644 index 0000000..6e90fcf --- /dev/null +++ b/NeoDB/NeoDB/References/IceCubes/Router.md @@ -0,0 +1,121 @@ +```swift +import Foundation +import SwiftUI +import Models +import Network + +public enum RouteurDestinations: Hashable { + case accountDetail(id: String) + case accountDetailWithAccount(account: Account) + case statusDetail(id: String) + case hashTag(tag: String, account: String?) + case list(list: Models.List) + case followers(id: String) + case following(id: String) + case favouritedBy(id: String) + case rebloggedBy(id: String) +} + +public enum SheetDestinations: Identifiable { + case newStatusEditor + case editStatusEditor(status: Status) + case replyToStatusEditor(status: Status) + case quoteStatusEditor(status: Status) + case listEdit(list: Models.List) + case listAddAccount(account: Account) + + public var id: String { + switch self { + case .editStatusEditor, .newStatusEditor, .replyToStatusEditor, .quoteStatusEditor: + return "statusEditor" + case .listEdit: + return "listEdit" + case .listAddAccount: + return "listAddAccount" + } + } +} + +@MainActor +public class RouterPath: ObservableObject { + public var client: Client? + + @Published public var path: [RouteurDestinations] = [] + @Published public var presentedSheet: SheetDestinations? + + public init() {} + + public func navigate(to: RouteurDestinations) { + path.append(to) + } + + public func handleStatus(status: AnyStatus, url: URL) -> OpenURLAction.Result { + if url.pathComponents.contains(where: { $0 == "tags" }), + let tag = url.pathComponents.last { + navigate(to: .hashTag(tag: tag, account: nil)) + return .handled + } else if let mention = status.mentions.first(where: { $0.url == url }) { + navigate(to: .accountDetail(id: mention.id)) + return .handled + } else if let client = client, + let id = Int(url.lastPathComponent) { + if url.absoluteString.contains(client.server) { + navigate(to: .statusDetail(id: String(id))) + } else { + Task { + await navigateToStatusFrom(url: url) + } + } + return .handled + } + return .systemAction + } + + public func handle(url: URL) -> OpenURLAction.Result { + if url.pathComponents.contains(where: { $0 == "tags" }), + let tag = url.pathComponents.last { + navigate(to: .hashTag(tag: tag, account: nil)) + return .handled + } else if url.lastPathComponent.first == "@", let host = url.host { + let acct = "\(url.lastPathComponent)@\(host)" + Task { + await navigateToAccountFrom(acct: acct, url: url) + } + return .handled + } + return .systemAction + } + + public func navigateToStatusFrom(url: URL) async { + guard let client else { return } + Task { + let results: SearchResults? = try? await client.get(endpoint: Search.search(query: url.absoluteString, + type: "statuses", + offset: nil, + following: nil), + forceVersion: .v2) + if let status = results?.statuses.first { + navigate(to: .statusDetail(id: status.id)) + } else { + await UIApplication.shared.open(url) + } + } + } + + public func navigateToAccountFrom(acct: String, url: URL) async { + guard let client else { return } + Task { + let results: SearchResults? = try? await client.get(endpoint: Search.search(query: acct, + type: "accounts", + offset: nil, + following: nil), + forceVersion: .v2) + if let account = results?.accounts.first { + navigate(to: .accountDetailWithAccount(account: account)) + } else { + await UIApplication.shared.open(url) + } + } + } +} +``` \ No newline at end of file diff --git a/NeoDB/NeoDB/Services/Models/ShelfModels.swift b/NeoDB/NeoDB/Services/Models/ShelfModels.swift new file mode 100644 index 0000000..a733324 --- /dev/null +++ b/NeoDB/NeoDB/Services/Models/ShelfModels.swift @@ -0,0 +1,99 @@ +import Foundation + +enum ShelfType: String, Codable, CaseIterable { + case wishlist + case progress + case complete + case dropped + + var displayName: String { + switch self { + case .wishlist: return "Want to Read" + case .progress: return "Reading" + case .complete: return "Completed" + case .dropped: return "Dropped" + } + } + + var systemImage: String { + switch self { + case .wishlist: return "star" + case .progress: return "book" + case .complete: return "checkmark.circle" + case .dropped: return "xmark.circle" + } + } +} + +enum ItemCategory: String, Codable { + case book, movie, tv, music, game, podcast + case performance, fanfic, exhibition, collection +} + +struct PagedMarkSchema: Codable { + let data: [MarkSchema] + let pages: Int + let count: Int +} + +struct MarkSchema: Codable, Identifiable { + var id: String { item.uuid } + let shelfType: ShelfType + let visibility: Int + let item: ItemSchema + let createdTime: Date + let commentText: String? + let ratingGrade: Int? + let tags: [String] + + enum CodingKeys: String, CodingKey { + case shelfType = "shelf_type" + case visibility + case item + case createdTime = "created_time" + case commentText = "comment_text" + case ratingGrade = "rating_grade" + case tags + } +} + +struct ItemSchema: Codable { + let title: String + let description: String + let localizedTitle: [LocalizedTitle] + let localizedDescription: [LocalizedTitle] + let coverImageUrl: String? + let rating: Double? + let ratingCount: Int? + let id: String + let type: String + let uuid: String + let url: String + let apiUrl: String + let category: ItemCategory + let parentUuid: String? + let displayTitle: String + let externalResources: [ExternalResource]? + + enum CodingKeys: String, CodingKey { + case title, description, type, id, uuid, url, category + case localizedTitle = "localized_title" + case localizedDescription = "localized_description" + case coverImageUrl = "cover_image_url" + case ratingCount = "rating_count" + case apiUrl = "api_url" + case parentUuid = "parent_uuid" + case displayTitle = "display_title" + case externalResources = "external_resources" + case rating + } +} + +struct LocalizedTitle: Codable { + let lang: String + let text: String +} + +struct ExternalResource: Codable { + let url: String +} diff --git a/NeoDB/NeoDB/Services/Models/Status.swift b/NeoDB/NeoDB/Services/Models/Status.swift deleted file mode 100644 index 948cf0e..0000000 --- a/NeoDB/NeoDB/Services/Models/Status.swift +++ /dev/null @@ -1,149 +0,0 @@ -import Foundation - -class Status: Codable, Identifiable { - let id: String - let uri: String - let createdAt: Date - let editedAt: Date? - let content: String - let text: String - let visibility: Visibility - let sensitive: Bool - let spoilerText: String - let url: String - let account: Account - let mediaAttachments: [MediaAttachment] - let mentions: [Mention] - let tags: [Tag] - let card: Card? - let language: String? - let favourited: Bool - let reblogged: Bool - let muted: Bool - let bookmarked: Bool - let pinned: Bool - let reblog: Status? - let favouritesCount: Int - let reblogsCount: Int - let repliesCount: Int - - enum Visibility: String, Codable { - case `public` - case unlisted - case `private` - case direct - } - - enum CodingKeys: String, CodingKey { - case id, uri, content, text, visibility, sensitive, url, account, mentions, tags, card, language - case createdAt = "created_at" - case editedAt = "edited_at" - case spoilerText = "spoiler_text" - case mediaAttachments = "media_attachments" - case favourited, reblogged, muted, bookmarked, pinned, reblog - case favouritesCount = "favourites_count" - case reblogsCount = "reblogs_count" - case repliesCount = "replies_count" - } -} - -struct Account: Codable, Identifiable { - let id: String - let username: String - let acct: String - let url: String - let displayName: String - let note: String - let avatar: String - let avatarStatic: String - let header: String - let headerStatic: String - let locked: Bool - let bot: Bool - let group: Bool - let discoverable: Bool - let indexable: Bool - let statusesCount: Int - let followersCount: Int - let followingCount: Int - let createdAt: Date - let lastStatusAt: String? - let fields: [Field] - let emojis: [Emoji] - - enum CodingKeys: String, CodingKey { - case id, username, acct, url, note, avatar, locked, bot, group, fields, emojis - case displayName = "display_name" - case avatarStatic = "avatar_static" - case header, headerStatic = "header_static" - case discoverable, indexable - case statusesCount = "statuses_count" - case followersCount = "followers_count" - case followingCount = "following_count" - case createdAt = "created_at" - case lastStatusAt = "last_status_at" - } -} - -struct Field: Codable { - let name: String - let value: String - let verifiedAt: Date? - - enum CodingKeys: String, CodingKey { - case name, value - case verifiedAt = "verified_at" - } -} - -struct Emoji: Codable { - let shortcode: String - let url: String - let staticUrl: String - let visibleInPicker: Bool - - enum CodingKeys: String, CodingKey { - case shortcode, url - case staticUrl = "static_url" - case visibleInPicker = "visible_in_picker" - } -} - -struct MediaAttachment: Codable, Identifiable { - let id: String - let type: MediaType - let url: String - let previewUrl: String? - let description: String? - - enum MediaType: String, Codable { - case image - case video - case gifv - case audio - case unknown - } - - enum CodingKeys: String, CodingKey { - case id, type, url, description - case previewUrl = "preview_url" - } -} - -struct Mention: Codable { - let username: String - let url: String - let acct: String -} - -struct Tag: Codable { - let name: String - let url: String -} - -struct Card: Codable { - let url: String - let title: String - let description: String - let image: String? -} \ No newline at end of file diff --git a/NeoDB/NeoDB/Services/Navigation/Router.swift b/NeoDB/NeoDB/Services/Navigation/Router.swift new file mode 100644 index 0000000..4edbb2b --- /dev/null +++ b/NeoDB/NeoDB/Services/Navigation/Router.swift @@ -0,0 +1,189 @@ +import SwiftUI +import OSLog + +enum RouterDestination: Hashable { + // Library + case itemDetail(id: String) + case itemDetailWithItem(item: ItemSchema) + case shelfDetail(type: ShelfType) + case userShelf(userId: String, type: ShelfType) + + // Social + case userProfile(id: String) + case userProfileWithUser(user: User) + case statusDetail(id: String) + case statusDetailWithStatus(status: Status) + case hashTag(tag: String) + + // Lists + case followers(id: String) + case following(id: String) + + func hash(into hasher: inout Hasher) { + switch self { + case .itemDetail(let id): + hasher.combine(0) + hasher.combine(id) + case .itemDetailWithItem(let item): + hasher.combine(1) + hasher.combine(item.id) + case .shelfDetail(let type): + hasher.combine(2) + hasher.combine(type) + case .userShelf(let userId, let type): + hasher.combine(3) + hasher.combine(userId) + hasher.combine(type) + case .userProfile(let id): + hasher.combine(4) + hasher.combine(id) + case .userProfileWithUser(let user): + hasher.combine(5) + hasher.combine(user.url) + case .statusDetail(let id): + hasher.combine(6) + hasher.combine(id) + case .statusDetailWithStatus(let status): + hasher.combine(7) + hasher.combine(status.id) + case .hashTag(let tag): + hasher.combine(8) + hasher.combine(tag) + case .followers(let id): + hasher.combine(9) + hasher.combine(id) + case .following(let id): + hasher.combine(10) + hasher.combine(id) + } + } + + static func == (lhs: RouterDestination, rhs: RouterDestination) -> Bool { + switch (lhs, rhs) { + case (.itemDetail(let id1), .itemDetail(let id2)): + return id1 == id2 + case (.itemDetailWithItem(let item1), .itemDetailWithItem(let item2)): + return item1.id == item2.id + case (.shelfDetail(let type1), .shelfDetail(let type2)): + return type1 == type2 + case (.userShelf(let userId1, let type1), .userShelf(let userId2, let type2)): + return userId1 == userId2 && type1 == type2 + case (.userProfile(let id1), .userProfile(let id2)): + return id1 == id2 + case (.userProfileWithUser(let user1), .userProfileWithUser(let user2)): + return user1.url == user2.url + case (.statusDetail(let id1), .statusDetail(let id2)): + return id1 == id2 + case (.statusDetailWithStatus(let status1), .statusDetailWithStatus(let status2)): + return status1.id == status2.id + case (.hashTag(let tag1), .hashTag(let tag2)): + return tag1 == tag2 + case (.followers(let id1), .followers(let id2)): + return id1 == id2 + case (.following(let id1), .following(let id2)): + return id1 == id2 + default: + return false + } + } +} + +enum SheetDestination: Identifiable { + case newStatus + case editStatus(status: Status) + case replyToStatus(status: Status) + case addToShelf(item: ItemSchema) + case editShelfItem(mark: MarkSchema) + + var id: String { + switch self { + case .newStatus, .editStatus, .replyToStatus: + return "statusEditor" + case .addToShelf: + return "shelfEditor" + case .editShelfItem: + return "shelfItemEditor" + } + } +} + +@MainActor +class Router: ObservableObject { + @Published var path: [RouterDestination] = [] + @Published var presentedSheet: SheetDestination? + + private let logger = Logger(subsystem: "social.neodb.app", category: "Router") + + func navigate(to destination: RouterDestination) { + path.append(destination) + logger.debug("Navigated to: \(String(describing: destination))") + } + + func dismissSheet() { + presentedSheet = nil + } + + func handleURL(_ url: URL) -> Bool { + logger.debug("Handling URL: \(url.absoluteString)") + + // Handle deep links and internal navigation + if url.host == "neodb.social" { + let pathComponents = url.pathComponents + + if pathComponents.contains("items"), + let id = pathComponents.last { + navigate(to: .itemDetail(id: id)) + return true + } + + if pathComponents.contains("users"), + let id = pathComponents.last { + navigate(to: .userProfile(id: id)) + return true + } + + if pathComponents.contains("status"), + let id = pathComponents.last { + navigate(to: .statusDetail(id: id)) + return true + } + + if pathComponents.contains("tags"), + let tag = pathComponents.last { + navigate(to: .hashTag(tag: tag)) + return true + } + } + + return false + } + + func handleStatus(status: Status, url: URL) -> Bool { + logger.debug("Handling status URL: \(url.absoluteString)") + + let pathComponents = url.pathComponents + + // Handle hashtags + if pathComponents.contains("tags"), + let tag = pathComponents.last { + navigate(to: .hashTag(tag: tag)) + return true + } + + // Handle mentions + if pathComponents.contains("users"), + let id = pathComponents.last { + navigate(to: .userProfile(id: id)) + return true + } + + // Handle status links + if pathComponents.contains("status"), + let id = pathComponents.last { + navigate(to: .statusDetail(id: id)) + return true + } + + return false + } +} \ No newline at end of file From ba6d616bac0c79fbcd9df2bc9fbc5dc4dbcd2cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=9C=E6=AA=B8Cirtron?= <45784494+lcandy2@users.noreply.github.com> Date: Thu, 9 Jan 2025 03:03:15 +0800 Subject: [PATCH 3/6] feat: enhance router functionality and improve view integration - Expanded the router implementation to support deep linking and centralized navigation management, including handling for new destinations such as status details and hashtags. - Updated HomeView and LibraryView to utilize the router for navigation, improving user experience and code organization. - Added logging for navigation actions to facilitate debugging and tracking of user interactions. - Revised documentation to reflect new features and next steps for further development, including the implementation of destination views and enhanced error handling. --- NeoDB/NeoDB/References/Cursor/router.md | 193 +++++++++++++++++--- NeoDB/NeoDB/Views/Home/HomeView.swift | 18 +- NeoDB/NeoDB/Views/Library/LibraryView.swift | 81 ++++---- 3 files changed, 217 insertions(+), 75 deletions(-) diff --git a/NeoDB/NeoDB/References/Cursor/router.md b/NeoDB/NeoDB/References/Cursor/router.md index f794ac5..c1cdbcc 100644 --- a/NeoDB/NeoDB/References/Cursor/router.md +++ b/NeoDB/NeoDB/References/Cursor/router.md @@ -1,7 +1,7 @@ # Router Implementation Design ## Overview -Implementing a navigation router system for NeoDB app, inspired by IceCubes' implementation. +Implementing a navigation router system for NeoDB app, inspired by IceCubes' implementation. The router provides centralized navigation management, type-safe routing, and deep linking support. ## Router Destinations Main navigation destinations in the app: @@ -59,9 +59,11 @@ Main router class that manages navigation state: class Router: ObservableObject { @Published var path: [RouterDestination] = [] @Published var presentedSheet: SheetDestination? + private let logger = Logger(subsystem: "social.neodb.app", category: "Router") func navigate(to destination: RouterDestination) { path.append(destination) + logger.debug("Navigated to: \(String(describing: destination))") } func dismissSheet() { @@ -69,26 +71,75 @@ class Router: ObservableObject { } func handleURL(_ url: URL) -> Bool { + logger.debug("Handling URL: \(url.absoluteString)") + // Handle deep links and internal navigation if url.host == "neodb.social" { - if let id = url.lastPathComponent { - if url.path.contains("/items/") { - navigate(to: .itemDetail(id: id)) - return true - } else if url.path.contains("/users/") { - navigate(to: .userProfile(id: id)) - return true - } + let pathComponents = url.pathComponents + + if pathComponents.contains("items"), + let id = pathComponents.last { + navigate(to: .itemDetail(id: id)) + return true + } + + if pathComponents.contains("users"), + let id = pathComponents.last { + navigate(to: .userProfile(id: id)) + return true + } + + if pathComponents.contains("status"), + let id = pathComponents.last { + navigate(to: .statusDetail(id: id)) + return true + } + + if pathComponents.contains("tags"), + let tag = pathComponents.last { + navigate(to: .hashTag(tag: tag)) + return true } } + + return false + } + + func handleStatus(status: Status, url: URL) -> Bool { + logger.debug("Handling status URL: \(url.absoluteString)") + + let pathComponents = url.pathComponents + + // Handle hashtags + if pathComponents.contains("tags"), + let tag = pathComponents.last { + navigate(to: .hashTag(tag: tag)) + return true + } + + // Handle mentions + if pathComponents.contains("users"), + let id = pathComponents.last { + navigate(to: .userProfile(id: id)) + return true + } + + // Handle status links + if pathComponents.contains("status"), + let id = pathComponents.last { + navigate(to: .statusDetail(id: id)) + return true + } + return false } } ``` -## Integration with SwiftUI -Example of integration in the main app structure: +## Integration with Views +Example of integration in views: +### ContentView ```swift struct ContentView: View { @StateObject private var router = Router() @@ -100,22 +151,21 @@ struct ContentView: View { .navigationDestination(for: RouterDestination.self) { destination in switch destination { case .itemDetail(let id): - ItemDetailView(id: id) + Text("Item Detail: \(id)") // TODO: Implement ItemDetailView case .userProfile(let id): - UserProfileView(id: id) + Text("User Profile: \(id)") // TODO: Implement UserProfileView // ... other cases } } } - // ... other tabs } .environmentObject(router) .sheet(item: $router.presentedSheet) { sheet in switch sheet { case .newStatus: - StatusEditorView() + Text("New Status") // TODO: Implement StatusEditorView case .addToShelf(let item): - ShelfEditorView(item: item) + Text("Add to Shelf: \(item.displayTitle)") // TODO: Implement ShelfEditorView // ... other cases } } @@ -123,24 +173,107 @@ struct ContentView: View { } ``` +### HomeView +```swift +struct HomeView: View { + @EnvironmentObject private var router: Router + + var body: some View { + ScrollView { + LazyVStack { + ForEach(viewModel.statuses) { status in + Button { + router.navigate(to: .statusDetailWithStatus(status: status)) + } label: { + StatusView(status: status) + } + .buttonStyle(.plain) + } + } + } + } +} +``` + +### LibraryView +```swift +struct LibraryView: View { + @EnvironmentObject private var router: Router + + var body: some View { + ScrollView { + LazyVStack { + ForEach(viewModel.shelfItems) { mark in + Button { + router.navigate(to: .itemDetailWithItem(item: mark.item)) + } label: { + ShelfItemView(mark: mark) + } + .buttonStyle(.plain) + } + } + } + } +} +``` + +## Deep Linking Support +The router handles deep links through the `handleURL` method in the app's entry point: + +```swift +struct NeoDBApp: App { + @StateObject private var authService = AuthService() + @StateObject private var router = Router() + + var body: some Scene { + WindowGroup { + Group { + if authService.isAuthenticated { + ContentView() + .environmentObject(authService) + .environmentObject(router) + } else { + LoginView() + .environmentObject(authService) + } + } + .onOpenURL { url in + // First try to handle OAuth callback + if url.scheme == "neodb" && url.host == "oauth" { + Task { + do { + try await authService.handleCallback(url: url) + } catch { + print("Authentication error: \(error)") + } + } + return + } + + // Then try to handle deep links + if !router.handleURL(url) { + // If the router didn't handle the URL, open it in the default browser + UIApplication.shared.open(url) + } + } + } + } +} +``` + ## Benefits 1. Centralized navigation management 2. Type-safe routing with enums 3. Deep linking support 4. Consistent navigation patterns 5. Easy to extend and maintain +6. Improved code organization +7. Better state management +8. Enhanced user experience -## Usage Examples -```swift -// In a view -@EnvironmentObject private var router: Router - -// Navigate -router.navigate(to: .itemDetail(id: "123")) - -// Present sheet -router.presentedSheet = .addToShelf(item: someItem) - -// Handle URL -router.handleURL(someURL) -``` \ No newline at end of file +## Next Steps +1. Implement destination views (ItemDetailView, StatusDetailView, etc.) +2. Add more navigation features as needed +3. Enhance deep linking support +4. Improve error handling and logging +5. Add analytics tracking for navigation events \ No newline at end of file diff --git a/NeoDB/NeoDB/Views/Home/HomeView.swift b/NeoDB/NeoDB/Views/Home/HomeView.swift index 750df17..93819ec 100644 --- a/NeoDB/NeoDB/Views/Home/HomeView.swift +++ b/NeoDB/NeoDB/Views/Home/HomeView.swift @@ -69,6 +69,7 @@ class HomeViewModel: ObservableObject { struct HomeView: View { @StateObject private var viewModel: HomeViewModel @Environment(\.colorScheme) private var colorScheme + @EnvironmentObject private var router: Router init(authService: AuthService) { let timelineService = TimelineService(authService: authService) @@ -103,14 +104,19 @@ struct HomeView: View { ScrollView { LazyVStack(spacing: 0) { ForEach(viewModel.statuses) { status in - StatusView(status: status) - .onAppear { - if status.id == viewModel.statuses.last?.id { - Task { - await viewModel.loadTimeline() + Button { + router.navigate(to: .statusDetailWithStatus(status: status)) + } label: { + StatusView(status: status) + .onAppear { + if status.id == viewModel.statuses.last?.id { + Task { + await viewModel.loadTimeline() + } } } - } + } + .buttonStyle(.plain) } if viewModel.isLoading { diff --git a/NeoDB/NeoDB/Views/Library/LibraryView.swift b/NeoDB/NeoDB/Views/Library/LibraryView.swift index 5b9a16a..d4332e7 100644 --- a/NeoDB/NeoDB/Views/Library/LibraryView.swift +++ b/NeoDB/NeoDB/Views/Library/LibraryView.swift @@ -1,6 +1,5 @@ import SwiftUI import OSLog -import Kingfisher @MainActor class LibraryViewModel: ObservableObject { @@ -72,6 +71,7 @@ class LibraryViewModel: ObservableObject { struct LibraryView: View { @StateObject private var viewModel: LibraryViewModel @Environment(\.colorScheme) private var colorScheme + @EnvironmentObject private var router: Router init(authService: AuthService) { let shelfService = ShelfService(authService: authService) @@ -79,41 +79,39 @@ struct LibraryView: View { } var body: some View { - NavigationStack { - VStack(spacing: 0) { - // Shelf Type Picker - ShelfFilterView( - selectedShelfType: $viewModel.selectedShelfType, - selectedCategory: $viewModel.selectedCategory, - onShelfTypeChange: viewModel.changeShelfType, - onCategoryChange: viewModel.changeCategory - ) - .padding(.horizontal) - .padding(.vertical, 8) - .background(Color(.systemBackground)) - - // Content - Group { - if let error = viewModel.error { - EmptyStateView( - "Couldn't Load Library", - systemImage: "exclamationmark.triangle", - description: Text(error) - ) - } else if viewModel.shelfItems.isEmpty && !viewModel.isLoading { - EmptyStateView( - "No Items Found", - systemImage: "books.vertical", - description: Text("Add some items to your \(viewModel.selectedShelfType.displayName.lowercased()) list") - ) - } else { - libraryContent - } + VStack(spacing: 0) { + // Shelf Type Picker + ShelfFilterView( + selectedShelfType: $viewModel.selectedShelfType, + selectedCategory: $viewModel.selectedCategory, + onShelfTypeChange: viewModel.changeShelfType, + onCategoryChange: viewModel.changeCategory + ) + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(.systemBackground)) + + // Content + Group { + if let error = viewModel.error { + EmptyStateView( + "Couldn't Load Library", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + } else if viewModel.shelfItems.isEmpty && !viewModel.isLoading { + EmptyStateView( + "No Items Found", + systemImage: "books.vertical", + description: Text("Add some items to your \(viewModel.selectedShelfType.displayName.lowercased()) list") + ) + } else { + libraryContent } } - .navigationTitle("Library") - .navigationBarTitleDisplayMode(.large) } + .navigationTitle("Library") + .navigationBarTitleDisplayMode(.large) .task { await viewModel.loadShelfItems() } @@ -123,14 +121,19 @@ struct LibraryView: View { ScrollView { LazyVStack(spacing: 12) { ForEach(viewModel.shelfItems) { mark in - ShelfItemView(mark: mark) - .onAppear { - if mark.id == viewModel.shelfItems.last?.id { - Task { - await viewModel.loadNextPage() + Button { + router.navigate(to: .itemDetailWithItem(item: mark.item)) + } label: { + ShelfItemView(mark: mark) + .onAppear { + if mark.id == viewModel.shelfItems.last?.id { + Task { + await viewModel.loadNextPage() + } } } - } + } + .buttonStyle(.plain) } if viewModel.isLoading { From 300ad0e9dd48fc9acd98659c98d0c3aab4789d51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=9C=E6=AA=B8Cirtron?= <45784494+lcandy2@users.noreply.github.com> Date: Thu, 9 Jan 2025 03:10:41 +0800 Subject: [PATCH 4/6] feat: add neodb logo assets - Introduced new logo assets for NeoDB, including a square SVG logo and its corresponding asset catalog configuration. - The logo is available in multiple scales (1x, 2x, 3x) for improved display across different device resolutions. - Updated asset catalog to include metadata for the new logo, enhancing the app's branding and visual identity. --- .../neodb-logo.imageset/Contents.json | 21 ++ .../neodb-logo.imageset/logo_square.svg | 193 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 NeoDB/NeoDB/Assets.xcassets/neodb-logo.imageset/Contents.json create mode 100644 NeoDB/NeoDB/Assets.xcassets/neodb-logo.imageset/logo_square.svg diff --git a/NeoDB/NeoDB/Assets.xcassets/neodb-logo.imageset/Contents.json b/NeoDB/NeoDB/Assets.xcassets/neodb-logo.imageset/Contents.json new file mode 100644 index 0000000..07e91db --- /dev/null +++ b/NeoDB/NeoDB/Assets.xcassets/neodb-logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo_square.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/NeoDB/NeoDB/Assets.xcassets/neodb-logo.imageset/logo_square.svg b/NeoDB/NeoDB/Assets.xcassets/neodb-logo.imageset/logo_square.svg new file mode 100644 index 0000000..76251fe --- /dev/null +++ b/NeoDB/NeoDB/Assets.xcassets/neodb-logo.imageset/logo_square.svg @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 5b877c22af800c6536b829c3c0c0f37af8b8cd48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=9C=E6=AA=B8Cirtron?= <45784494+lcandy2@users.noreply.github.com> Date: Thu, 9 Jan 2025 03:10:48 +0800 Subject: [PATCH 5/6] feat: enhance navigation and shelf management features - Integrated router functionality for improved navigation across user profiles, item details, and shelf management. - Added new navigation examples and deep linking support in the router documentation. - Migrated shelf display functionality from ProfileView to a dedicated LibraryView, enhancing organization and user experience. - Implemented pagination, filtering, and deep linking for the LibraryView, allowing users to manage their collections more effectively. - Updated StatusView to utilize the router for user profile navigation, improving interaction and accessibility. --- .../References/Cursor/auth_and_profile.md | 67 ++++ NeoDB/NeoDB/References/Cursor/router.md | 310 ++++++------------ .../NeoDB/References/Cursor/shelf_feature.md | 149 ++++++--- .../Views/Home/Components/StatusView.swift | 56 ++-- 4 files changed, 311 insertions(+), 271 deletions(-) diff --git a/NeoDB/NeoDB/References/Cursor/auth_and_profile.md b/NeoDB/NeoDB/References/Cursor/auth_and_profile.md index 4a685f2..7c08fbc 100644 --- a/NeoDB/NeoDB/References/Cursor/auth_and_profile.md +++ b/NeoDB/NeoDB/References/Cursor/auth_and_profile.md @@ -9,6 +9,7 @@ graph TD B --> C[ProfileViewModel] C --> D[ProfileView] A --> D + E[Router] --> D ``` ### State Management @@ -20,6 +21,10 @@ graph TD // Profile-level state @Published var user: User? @Published var isLoading: Bool + +// Navigation state +@Published var path: [RouterDestination] +@Published var presentedSheet: SheetDestination? ``` ## Key Components @@ -55,6 +60,51 @@ class UserService { } ``` +### Router Integration +```swift +// Profile-related destinations +case userProfile(id: String) +case userProfileWithUser(user: User) +case followers(id: String) +case following(id: String) + +// Navigation in views +Button { + router.navigate(to: .userProfile(id: account.id)) +} label: { + KFImage(URL(string: account.avatar)) + .placeholder { ... } + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 44, height: 44) + .clipShape(Circle()) +} + +// For User type +Button { + router.navigate(to: .userProfileWithUser(user: user)) +} label: { + UserAvatarView(user: user) +} +``` + +### Navigation Examples +```swift +// In StatusView +Button { + router.navigate(to: .userProfile(id: status.account.id)) +} label: { + // Avatar or username view +} + +// In ProfileView +Button { + router.navigate(to: .followers(id: user.id)) +} label: { + Text("Followers") +} +``` + ### Cache Strategy ```swift // Cache key format @@ -100,6 +150,13 @@ KFImage(URL(string: user.avatar)) .clipShape(Circle()) ``` +### Navigation Features +- Profile view navigation +- Followers/Following lists +- Deep linking support +- Back navigation +- Sheet presentations + ### Image Loading Features - Automatic caching - Placeholder support @@ -149,11 +206,14 @@ enum AuthError { - [ ] Background sync - [ ] Rate limiting - [ ] Error retry mechanism +- [ ] Analytics tracking +- [ ] Deep linking enhancements ### Performance Optimizations - Preload avatar images - Cache size limits - Background data prefetch +- Navigation state persistence ## API Endpoints @@ -173,4 +233,11 @@ Response: { avatar: string username: string } +``` + +### Navigation +``` +/users/{id} +/users/{id}/followers +/users/{id}/following ``` \ No newline at end of file diff --git a/NeoDB/NeoDB/References/Cursor/router.md b/NeoDB/NeoDB/References/Cursor/router.md index c1cdbcc..c26faaf 100644 --- a/NeoDB/NeoDB/References/Cursor/router.md +++ b/NeoDB/NeoDB/References/Cursor/router.md @@ -27,6 +27,59 @@ enum RouterDestination: Hashable { } ``` +## Navigation Examples + +### Profile Navigation +```swift +// Navigate to user profile using ID +Button { + router.navigate(to: .userProfile(id: status.account.id)) +} label: { + // Avatar or username view +} + +// Navigate to user profile with User object +Button { + router.navigate(to: .userProfileWithUser(user: user)) +} label: { + UserAvatarView(user: user) +} +``` + +### Status Navigation +```swift +// Navigate to status detail +Button { + router.navigate(to: .statusDetail(id: status.id)) +} label: { + // Status content view +} + +// Navigate with full status object +Button { + router.navigate(to: .statusDetailWithStatus(status: status)) +} label: { + StatusView(status: status) +} +``` + +### Library Navigation +```swift +// Navigate to item detail +Button { + router.navigate(to: .itemDetail(id: item.id)) +} label: { + // Item preview +} + +// Navigate to shelf +Button { + router.navigate(to: .shelfDetail(type: .wishlist)) +} label: { + Text("Want to Read") +} +``` + ## Sheet Destinations Modal presentations in the app: @@ -51,229 +104,62 @@ enum SheetDestination: Identifiable { } ``` -## Router Implementation -Main router class that manages navigation state: +## Deep Linking Support -```swift -@MainActor -class Router: ObservableObject { - @Published var path: [RouterDestination] = [] - @Published var presentedSheet: SheetDestination? - private let logger = Logger(subsystem: "social.neodb.app", category: "Router") - - func navigate(to destination: RouterDestination) { - path.append(destination) - logger.debug("Navigated to: \(String(describing: destination))") - } - - func dismissSheet() { - presentedSheet = nil - } - - func handleURL(_ url: URL) -> Bool { - logger.debug("Handling URL: \(url.absoluteString)") - - // Handle deep links and internal navigation - if url.host == "neodb.social" { - let pathComponents = url.pathComponents - - if pathComponents.contains("items"), - let id = pathComponents.last { - navigate(to: .itemDetail(id: id)) - return true - } - - if pathComponents.contains("users"), - let id = pathComponents.last { - navigate(to: .userProfile(id: id)) - return true - } - - if pathComponents.contains("status"), - let id = pathComponents.last { - navigate(to: .statusDetail(id: id)) - return true - } - - if pathComponents.contains("tags"), - let tag = pathComponents.last { - navigate(to: .hashTag(tag: tag)) - return true - } - } - - return false - } - - func handleStatus(status: Status, url: URL) -> Bool { - logger.debug("Handling status URL: \(url.absoluteString)") - - let pathComponents = url.pathComponents - - // Handle hashtags - if pathComponents.contains("tags"), - let tag = pathComponents.last { - navigate(to: .hashTag(tag: tag)) - return true - } - - // Handle mentions - if pathComponents.contains("users"), - let id = pathComponents.last { - navigate(to: .userProfile(id: id)) - return true - } - - // Handle status links - if pathComponents.contains("status"), - let id = pathComponents.last { - navigate(to: .statusDetail(id: id)) - return true - } - - return false - } -} +### URL Patterns ``` - -## Integration with Views -Example of integration in views: - -### ContentView -```swift -struct ContentView: View { - @StateObject private var router = Router() - - var body: some View { - TabView { - NavigationStack(path: $router.path) { - HomeView() - .navigationDestination(for: RouterDestination.self) { destination in - switch destination { - case .itemDetail(let id): - Text("Item Detail: \(id)") // TODO: Implement ItemDetailView - case .userProfile(let id): - Text("User Profile: \(id)") // TODO: Implement UserProfileView - // ... other cases - } - } - } - } - .environmentObject(router) - .sheet(item: $router.presentedSheet) { sheet in - switch sheet { - case .newStatus: - Text("New Status") // TODO: Implement StatusEditorView - case .addToShelf(let item): - Text("Add to Shelf: \(item.displayTitle)") // TODO: Implement ShelfEditorView - // ... other cases - } - } - } -} +/items/{id} +/users/{id} +/users/{id}/shelf/{type} +/status/{id} +/tags/{tag} ``` -### HomeView +### URL Handling ```swift -struct HomeView: View { - @EnvironmentObject private var router: Router - - var body: some View { - ScrollView { - LazyVStack { - ForEach(viewModel.statuses) { status in - Button { - router.navigate(to: .statusDetailWithStatus(status: status)) - } label: { - StatusView(status: status) - } - .buttonStyle(.plain) - } - } - } +func handleURL(_ url: URL) -> Bool { + if url.pathComponents.contains("items"), + let id = url.pathComponents.last { + navigate(to: .itemDetail(id: id)) + return true } -} -``` - -### LibraryView -```swift -struct LibraryView: View { - @EnvironmentObject private var router: Router - var body: some View { - ScrollView { - LazyVStack { - ForEach(viewModel.shelfItems) { mark in - Button { - router.navigate(to: .itemDetailWithItem(item: mark.item)) - } label: { - ShelfItemView(mark: mark) - } - .buttonStyle(.plain) - } - } - } + if url.pathComponents.contains("users"), + let id = url.pathComponents.last { + navigate(to: .userProfile(id: id)) + return true } + + // ... other patterns + return false } ``` -## Deep Linking Support -The router handles deep links through the `handleURL` method in the app's entry point: +## Best Practices -```swift -struct NeoDBApp: App { - @StateObject private var authService = AuthService() - @StateObject private var router = Router() - - var body: some Scene { - WindowGroup { - Group { - if authService.isAuthenticated { - ContentView() - .environmentObject(authService) - .environmentObject(router) - } else { - LoginView() - .environmentObject(authService) - } - } - .onOpenURL { url in - // First try to handle OAuth callback - if url.scheme == "neodb" && url.host == "oauth" { - Task { - do { - try await authService.handleCallback(url: url) - } catch { - print("Authentication error: \(error)") - } - } - return - } - - // Then try to handle deep links - if !router.handleURL(url) { - // If the router didn't handle the URL, open it in the default browser - UIApplication.shared.open(url) - } - } - } - } -} -``` +### Navigation +1. Use IDs for navigation when possible +2. Pass full objects only when needed for immediate display +3. Handle deep links gracefully +4. Support back navigation +5. Maintain navigation stack state + +### Error Handling +1. Log navigation failures +2. Provide fallback routes +3. Handle invalid URLs +4. Support external URLs -## Benefits -1. Centralized navigation management -2. Type-safe routing with enums -3. Deep linking support -4. Consistent navigation patterns -5. Easy to extend and maintain -6. Improved code organization -7. Better state management -8. Enhanced user experience +### Performance +1. Minimize object passing +2. Cache navigation state +3. Preload common destinations +4. Clean up navigation stack -## Next Steps -1. Implement destination views (ItemDetailView, StatusDetailView, etc.) -2. Add more navigation features as needed -3. Enhance deep linking support -4. Improve error handling and logging -5. Add analytics tracking for navigation events \ No newline at end of file +## Future Improvements +- [ ] Navigation history +- [ ] Custom transitions +- [ ] Nested navigation +- [ ] Route analytics +- [ ] State restoration +- [ ] URL scheme expansion \ No newline at end of file diff --git a/NeoDB/NeoDB/References/Cursor/shelf_feature.md b/NeoDB/NeoDB/References/Cursor/shelf_feature.md index 6ba1af6..3de25a4 100644 --- a/NeoDB/NeoDB/References/Cursor/shelf_feature.md +++ b/NeoDB/NeoDB/References/Cursor/shelf_feature.md @@ -1,7 +1,7 @@ # Shelf Feature Implementation ## Overview -Shelf display functionality moved from ProfileView to dedicated LibraryView tab for better organization and user experience. +Shelf display functionality in dedicated LibraryView tab for collection management. Supports navigation to item details, filtering, and shelf management. ## API Endpoints - GET `/api/me/shelf/{type}` @@ -15,11 +15,36 @@ Shelf display functionality moved from ProfileView to dedicated LibraryView tab 3. PagedMarkSchema - Paginated response structure 4. ItemSchema - Item details structure -## Implementation Plan -1. Create LibraryView and LibraryViewModel -2. Move shelf UI components from ProfileView -3. Enhance shelf display for dedicated tab view -4. Implement pagination and filtering +## Router Integration + +### Destinations +```swift +// Library destinations +case itemDetail(id: String) +case itemDetailWithItem(item: ItemSchema) +case shelfDetail(type: ShelfType) +case userShelf(userId: String, type: ShelfType) + +// Sheet destinations +case addToShelf(item: ItemSchema) +case editShelfItem(mark: MarkSchema) +``` + +### Navigation Examples +```swift +// Navigate to item detail +Button { + router.navigate(to: .itemDetailWithItem(item: mark.item)) +} label: { + ShelfItemView(mark: mark) +} + +// Present add to shelf sheet +router.presentedSheet = .addToShelf(item: item) + +// Navigate to user's shelf +router.navigate(to: .userShelf(userId: user.id, type: .wishlist)) +``` ## Component Structure - LibraryView/ @@ -35,37 +60,87 @@ Shelf display functionality moved from ProfileView to dedicated LibraryView tab - Infinite scrolling pagination - Pull-to-refresh - Loading states and error handling +- Deep linking support +- Navigation integration + +## Implementation Details + +### LibraryView +```swift +struct LibraryView: View { + @StateObject private var viewModel: LibraryViewModel + @EnvironmentObject private var router: Router + + var body: some View { + VStack { + ShelfFilterView(...) + + ScrollView { + LazyVStack { + ForEach(viewModel.shelfItems) { mark in + Button { + router.navigate(to: .itemDetailWithItem(item: mark.item)) + } label: { + ShelfItemView(mark: mark) + } + } + } + } + } + } +} +``` + +### ShelfItemView +```swift +struct ShelfItemView: View { + let mark: MarkSchema + + var body: some View { + HStack { + KFImage(URL(string: mark.item.coverImageUrl)) + .placeholder { ... } + + VStack(alignment: .leading) { + Text(mark.item.displayTitle) + if let rating = mark.ratingGrade { + RatingView(rating: rating) + } + TagsView(tags: mark.tags) + } + } + } +} +``` + +## Deep Linking +Support for deep links to: +- Specific items +- User shelves +- Shelf types +- Categories + +URL patterns: +``` +/items/{id} +/users/{id}/shelf/{type} +/shelf/{type}?category={category} +``` -## Changes -1. Initial Implementation: - - Added ShelfService.swift for API communication - - Created shelf models - - Implemented shelf UI in ProfileView - -2. Migration to Library Tab: - - Created dedicated LibraryView - - Moved shelf functionality from ProfileView - - Enhanced UI for full-screen display - - Improved navigation and filtering - - Updated ContentView to include Library tab - - Restored ProfileView to its original state - -## Design Rationale -- Dedicated tab provides better visibility for collection management -- Separates profile information from content management -- More space for enhanced shelf features -- Clearer navigation structure - -## Migration Process -1. Created new LibraryView and components -2. Moved shelf functionality from ProfileView -3. Updated tab bar in ContentView -4. Removed shelf-related code from ProfileView -5. Organized components into proper directory structure +## Error Handling +- Network errors +- Invalid data +- Loading states +- Empty states +- Retry mechanisms ## Future Improvements -- Add sorting options -- Implement search within library -- Add batch actions for multiple items -- Enhance item details view -- Add statistics and reading progress \ No newline at end of file +- Batch actions +- Sorting options +- Search within library +- Enhanced filters +- Statistics view +- Reading progress +- Share functionality +- Export/Import +- Offline support \ No newline at end of file diff --git a/NeoDB/NeoDB/Views/Home/Components/StatusView.swift b/NeoDB/NeoDB/Views/Home/Components/StatusView.swift index 42024b0..742cfc5 100644 --- a/NeoDB/NeoDB/Views/Home/Components/StatusView.swift +++ b/NeoDB/NeoDB/Views/Home/Components/StatusView.swift @@ -6,35 +6,42 @@ import MarkdownUI struct StatusView: View { let status: Status @Environment(\.openURL) private var openURL + @EnvironmentObject private var router: Router var body: some View { VStack(alignment: .leading, spacing: 12) { // Header HStack(spacing: 8) { - KFImage(URL(string: status.account.avatar)) - .placeholder { - Circle() - .fill(Color.gray.opacity(0.2)) - .frame(width: 44, height: 44) - } - .onFailure { _ in - Image(systemName: "person.circle.fill") - .symbolRenderingMode(.hierarchical) + Button { + router.navigate(to: .userProfile(id: status.account.id)) + } label: { + KFImage(URL(string: status.account.avatar)) + .placeholder { + Circle() + .fill(Color.gray.opacity(0.2)) + .frame(width: 44, height: 44) + } + .onFailure { _ in + Image(systemName: "person.circle.fill") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .font(.system(size: 44)) + } + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 44, height: 44) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 2) { + Text(status.account.displayName) + .font(.headline) + .foregroundStyle(.primary) + Text("@\(status.account.username)") + .font(.subheadline) .foregroundStyle(.secondary) - .font(.system(size: 44)) } - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 44, height: 44) - .clipShape(Circle()) - - VStack(alignment: .leading, spacing: 2) { - Text(status.account.displayName) - .font(.headline) - Text("@\(status.account.username)") - .font(.subheadline) - .foregroundStyle(.secondary) } + .buttonStyle(.plain) Spacer() @@ -80,7 +87,12 @@ struct StatusView: View { } .padding() .background(Color(.systemBackground)) + .enableInjection() } + + #if DEBUG + @ObserveInjection var forceRedraw + #endif @ViewBuilder private var mediaGrid: some View { @@ -111,4 +123,4 @@ struct StatusView: View { } } } -} \ No newline at end of file +} From a1dc6d8212de48de243127204efd41120b505a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=9C=E6=AA=B8Cirtron?= <45784494+lcandy2@users.noreply.github.com> Date: Thu, 9 Jan 2025 03:13:02 +0800 Subject: [PATCH 6/6] chore: update project.pbxproj to include new documentation files - Added new documentation files for router functionality and shelf features to the project configuration. - Included references for router.md and IceCubes/Router.md to enhance navigation documentation. - Improved organization of project resources by updating membership exceptions in the project file. --- NeoDB/NeoDB.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NeoDB/NeoDB.xcodeproj/project.pbxproj b/NeoDB/NeoDB.xcodeproj/project.pbxproj index 5fb2428..a0dbb2b 100644 --- a/NeoDB/NeoDB.xcodeproj/project.pbxproj +++ b/NeoDB/NeoDB.xcodeproj/project.pbxproj @@ -44,8 +44,10 @@ membershipExceptions = ( Info.plist, References/Cursor/auth_and_profile.md, + References/Cursor/router.md, References/Cursor/shelf_feature.md, References/Cursor/timeline_local.md, + References/IceCubes/Router.md, References/Mastodon/timelines.md, References/NeoDB/api.md, References/NeoDB/openapi/openapi.yaml,