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, 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/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 new file mode 100644 index 0000000..c26faaf --- /dev/null +++ b/NeoDB/NeoDB/References/Cursor/router.md @@ -0,0 +1,165 @@ +# Router Implementation Design + +## Overview +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: + +```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) +} +``` + +## 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: + +```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" + } + } +} +``` + +## Deep Linking Support + +### URL Patterns +``` +/items/{id} +/users/{id} +/users/{id}/shelf/{type} +/status/{id} +/tags/{tag} +``` + +### URL Handling +```swift +func handleURL(_ url: URL) -> Bool { + if url.pathComponents.contains("items"), + let id = url.pathComponents.last { + navigate(to: .itemDetail(id: id)) + return true + } + + if url.pathComponents.contains("users"), + let id = url.pathComponents.last { + navigate(to: .userProfile(id: id)) + return true + } + + // ... other patterns + return false +} +``` + +## Best Practices + +### 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 + +### Performance +1. Minimize object passing +2. Cache navigation state +3. Preload common destinations +4. Clean up navigation stack + +## 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/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/Models/Shelf/ShelfModels.swift b/NeoDB/NeoDB/Services/Models/ShelfModels.swift similarity index 99% rename from NeoDB/NeoDB/Models/Shelf/ShelfModels.swift rename to NeoDB/NeoDB/Services/Models/ShelfModels.swift index e731682..a733324 100644 --- a/NeoDB/NeoDB/Models/Shelf/ShelfModels.swift +++ b/NeoDB/NeoDB/Services/Models/ShelfModels.swift @@ -96,4 +96,4 @@ struct LocalizedTitle: Codable { 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/Timeline/Status.swift similarity index 100% rename from NeoDB/NeoDB/Models/Timeline/Status.swift rename to NeoDB/NeoDB/Services/Models/Timeline/Status.swift 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 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 +} 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 {