diff --git a/ios/HackerNews.xcodeproj/project.pbxproj b/ios/HackerNews.xcodeproj/project.pbxproj index 9c729c35..4c4ea4d6 100644 --- a/ios/HackerNews.xcodeproj/project.pbxproj +++ b/ios/HackerNews.xcodeproj/project.pbxproj @@ -43,6 +43,8 @@ C3AC6AD62CB6E81B006BD22D /* HackerNewsSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3AC6AD52CB6E816006BD22D /* HackerNewsSnapshotTest.swift */; }; C3AC6AD92CB6E8F7006BD22D /* SnapshottingTests in Frameworks */ = {isa = PBXBuildFile; productRef = C3AC6AD82CB6E8F7006BD22D /* SnapshottingTests */; }; EC072CAA2CEF02A500D00B8D /* StoryRowV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC072CA92CEF02A500D00B8D /* StoryRowV2.swift */; }; + EC29FDA72CFFD074007B1AE9 /* BookmarksScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC29FDA62CFFD074007B1AE9 /* BookmarksScreen.swift */; }; + EC29FDA92CFFD0B5007B1AE9 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC29FDA82CFFD0B5007B1AE9 /* SettingsScreen.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -138,6 +140,8 @@ C3AC6AD52CB6E816006BD22D /* HackerNewsSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HackerNewsSnapshotTest.swift; sourceTree = ""; }; C3AC6AD72CB6E854006BD22D /* HackerNews.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HackerNews.xctestplan; sourceTree = ""; }; EC072CA92CEF02A500D00B8D /* StoryRowV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryRowV2.swift; sourceTree = ""; }; + EC29FDA62CFFD074007B1AE9 /* BookmarksScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksScreen.swift; sourceTree = ""; }; + EC29FDA82CFFD0B5007B1AE9 /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -213,6 +217,8 @@ A42705A82A4294EB0057E439 /* LoginScreen.swift */, A42705AA2A4296BA0057E439 /* PostListScreen.swift */, A48C0DE62A9818A50034CC0A /* StoryScreen.swift */, + EC29FDA62CFFD074007B1AE9 /* BookmarksScreen.swift */, + EC29FDA82CFFD0B5007B1AE9 /* SettingsScreen.swift */, ); path = Screens; sourceTree = ""; @@ -559,6 +565,7 @@ buildActionMask = 2147483647; files = ( A423B06A2BAE061600267DDB /* Flags.swift in Sources */, + EC29FDA92CFFD0B5007B1AE9 /* SettingsScreen.swift in Sources */, 1DF161FA2A4346F6001A3F76 /* StoryRow.swift in Sources */, 1DF162002A4365A1001A3F76 /* Colors.swift in Sources */, A42705AB2A4296BA0057E439 /* PostListScreen.swift in Sources */, @@ -567,6 +574,7 @@ EC072CAA2CEF02A500D00B8D /* StoryRowV2.swift in Sources */, 1DF162032A436E7B001A3F76 /* DateUtils.swift in Sources */, A413E8572A8C40E800C0F867 /* Extensions.swift in Sources */, + EC29FDA72CFFD074007B1AE9 /* BookmarksScreen.swift in Sources */, A42705A92A4294EB0057E439 /* LoginScreen.swift in Sources */, A413E8592A8D868500C0F867 /* ThemedButtonStyle.swift in Sources */, A427057D2A4293B10057E439 /* HNApp.swift in Sources */, diff --git a/ios/HackerNews/Components/StoryRow.swift b/ios/HackerNews/Components/StoryRow.swift index 4b05bed1..ce2a27e6 100644 --- a/ios/HackerNews/Components/StoryRow.swift +++ b/ios/HackerNews/Components/StoryRow.swift @@ -9,77 +9,45 @@ import Foundation import SwiftUI struct StoryRow: View { - @ObservedObject var appState: AppViewModel + @ObservedObject var model: AppViewModel let story: Story - let index: Int var body: some View { - VStack(alignment: .leading) { - HStack { + VStack(alignment: .leading, spacing: 8) { + let author = story.by! + Text("@\(author)") + .foregroundColor(.hnOrange) + .fontWeight(/*@START_MENU_TOKEN@*/.bold/*@END_MENU_TOKEN@*/) + Text(story.title) + .font(.headline) + HStack(spacing: 16) { + HStack(spacing: 4) { + Image(systemName: "arrow.up") + .foregroundColor(.green) + Text("\(story.score)") + } + HStack(spacing: 4) { + Image(systemName: "clock") + .foregroundColor(.purple) + Text(story.displayableDate) + } + Spacer() + // Comment Button Button(action: { - // TODO: Add upvote action - print("Pressed upvote for \(story.title)") + print("Pressed comment button for: \(story.id)") + model.navigationPath.append( + AppViewModel.AppNavigation.storyComments(story: story) + ) }) { - VStack { - Image(systemName: "arrow.up") - .foregroundColor(Color.primary) - Text("\(story.score)") - .font(.caption) - .foregroundColor(Color.primary) - } - } - .padding(.trailing, 4) - - VStack(alignment: .leading) { - Text("\(index + 1). \(story.title)") - .font(.subheadline) - .foregroundColor(Color.primary) - - if let displayableUrl = story.displayableUrl { - Text("(\(displayableUrl))") - .font(.caption2) - .foregroundColor(Color.primary.opacity(0.6)) - } - HStack { - let dateAndAuthor: String = { - if let author = story.by { - return "\(story.displayableDate) by \(author)" - } else { - return story.displayableDate - } - }() - Text(dateAndAuthor) - .font(.caption) - .foregroundColor(Color.primary.opacity(0.6)) - - if story.commentCount > 0 { - Button(action: { - appState.navigationPath.append(AppViewModel.AppNavigation.storyComments(story: story)) - }) { - let commentText: String = { - if story.commentCount == 1 { - return "\(story.commentCount) comment" - } else { - return "\(story.commentCount) comments" - } - }() - Text(commentText) - .font(.caption) - .fontWeight(.medium) - .underline() - .foregroundColor(Color.primary) - } - .buttonStyle(.borderedProminent) - .tint(HNColors.commentBackground) - .frame(maxWidth: .infinity, alignment: .trailing) - } + HStack(spacing: 4) { + Image(systemName: "message.fill") + Text("\(story.commentCount)") } - .padding(.horizontal, 2) - .padding(.top, 2) } + .buttonStyle(.bordered) + .buttonBorderShape(ButtonBorderShape.capsule) } } - .padding(.top, 4) } } @@ -87,7 +55,7 @@ struct StoryRow_Preview: PreviewProvider { static var previews: some View { let fakeStory = PreviewHelpers.makeFakeStory(index: 0, descendants: 3, kids: [1, 2, 3]) PreviewVariants { - StoryRow(appState: AppViewModel(), story: fakeStory, index: 0) + StoryRow(model: AppViewModel(), story: fakeStory) } } } diff --git a/ios/HackerNews/Components/StoryRowV2.swift b/ios/HackerNews/Components/StoryRowV2.swift index 38ea9dda..3397626c 100644 --- a/ios/HackerNews/Components/StoryRowV2.swift +++ b/ios/HackerNews/Components/StoryRowV2.swift @@ -12,49 +12,7 @@ struct StoryRowV2: View { let story: Story var body: some View { - VStack(alignment: .leading, spacing: 8) { - let author = story.by! - Text("@\(author)") - .foregroundColor(.hnOrange) - .fontWeight(/*@START_MENU_TOKEN@*/.bold/*@END_MENU_TOKEN@*/) - Text(story.title) - .font(.headline) - HStack(spacing: 16) { - HStack(spacing: 4) { - Image(systemName: "arrow.up") - .foregroundColor(.green) - Text("\(story.score)") - } - HStack(spacing: 4) { - Image(systemName: "clock") - .foregroundColor(.purple) - Text(story.displayableDate) - } - Spacer() - // Comment Button - Button(action: { - print("Pressed comment button for: \(story.id)") - model.navigationPath.append( - AppViewModel.AppNavigation.storyComments(story: story) - ) - }) { - HStack(spacing: 4) { - Image(systemName: "message.fill") - Text("\(story.commentCount)") - } - } - .buttonStyle(.bordered) - .buttonBorderShape(ButtonBorderShape.capsule) - } - } + Text("Hello There") } } -struct StoryRowV2_Preview: PreviewProvider { - static var previews: some View { - let fakeStory = PreviewHelpers.makeFakeStory(index: 0, descendants: 3, kids: [1, 2, 3]) - PreviewVariants { - StoryRowV2(model: AppViewModel(), story: fakeStory) - } - } -} diff --git a/ios/HackerNews/ContentView.swift b/ios/HackerNews/ContentView.swift index 67815381..7a9d7ab9 100644 --- a/ios/HackerNews/ContentView.swift +++ b/ios/HackerNews/ContentView.swift @@ -14,7 +14,17 @@ struct ContentView: View { var body: some View { switch appState.authState { case .loggedIn: - PostListScreen(appState: appState) + TabView { + PostListScreen(appState: appState) + .tag(1) + .tabItem { Label("Feed", systemImage: "list.dash") } + BookmarksScreen() + .tag(2) + .tabItem { Label("Bookmarks", systemImage: "book") } + SettingsScreen() + .tag(3) + .tabItem { Label("Settings", systemImage: "gear") } + } case .loggedOut: LoginScreen(appState: appState) } @@ -38,7 +48,7 @@ struct ContentView_LoggedIn_Loading_Previews: PreviewProvider { static var previews: some View { let appModel = AppViewModel() appModel.authState = .loggedIn - appModel.storiesState = .loading + appModel.postListState = PostListState(storiesState: .loading) return PreviewVariants { PreviewHelpers.withNavigationView { ContentView(appState: appModel) @@ -51,7 +61,9 @@ struct ContentView_LoggedIn_WithPosts_Previews: PreviewProvider { static var previews: some View { let appModel = AppViewModel() appModel.authState = .loggedIn - appModel.storiesState = .loaded(stories: PreviewHelpers.makeFakeStories()) + appModel.postListState = PostListState( + storiesState: .loaded(items: PreviewHelpers.makeFakeStories()) + ) return PreviewVariants { PreviewHelpers.withNavigationView { ContentView(appState: appModel) @@ -64,7 +76,9 @@ struct ContentView_LoggedIn_EmptyPosts_Previews: PreviewProvider { static var previews: some View { let appModel = AppViewModel() appModel.authState = .loggedIn - appModel.storiesState = .loaded(stories: []) + appModel.postListState = PostListState( + storiesState: .loaded(items: []) + ) return PreviewVariants { PreviewHelpers.withNavigationView { ContentView(appState: appModel) diff --git a/ios/HackerNews/Models/AppViewModel.swift b/ios/HackerNews/Models/AppViewModel.swift index aa6aaa94..ff3fb654 100644 --- a/ios/HackerNews/Models/AppViewModel.swift +++ b/ios/HackerNews/Models/AppViewModel.swift @@ -8,6 +8,41 @@ import Foundation import SwiftUI +enum FeedType: CaseIterable { + case top + case new + case best + case ask + case show + + var title: String { + switch self { + case .top: + return "Top" + case .new: + return "New" + case .best: + return "Best" + case .ask: + return "Ask" + case .show: + return "Show" + } + } +} + +enum StoriesState { + case notStarted + case loading + case loaded(items: [Story]) +} + +struct PostListState { + var feeds: [FeedType] = FeedType.allCases + var storiesState: StoriesState = .notStarted + var selectedFeed: FeedType = FeedType.top +} + @MainActor class AppViewModel: ObservableObject { @@ -21,14 +56,9 @@ class AppViewModel: ObservableObject { case loggedOut } - enum StoriesListState { - case notStarted - case loading - case loaded(stories: [Story]) - } @Published var authState = AuthState.loggedOut - @Published var storiesState = StoriesListState.notStarted + @Published var postListState = PostListState() @Published var navigationPath = NavigationPath() private let hnApi = HNApi() @@ -43,10 +73,14 @@ class AppViewModel: ObservableObject { authState = .loggedOut } - func fetchPosts() async { - storiesState = .loading - let stories = await hnApi.fetchTopStories() - storiesState = .loaded(stories: stories) + func fetchPosts(feedType: FeedType) async { + var updated = postListState + updated.selectedFeed = feedType + updated.storiesState = .loading + postListState = updated + + let stories = await hnApi.fetchStories(feedType: feedType) + updated.storiesState = .loaded(items: stories) + postListState = updated } - } diff --git a/ios/HackerNews/Network/HNApi.swift b/ios/HackerNews/Network/HNApi.swift index e1082b3a..a72da1c9 100644 --- a/ios/HackerNews/Network/HNApi.swift +++ b/ios/HackerNews/Network/HNApi.swift @@ -9,20 +9,36 @@ import Foundation class HNApi { + let baseUrl = "https://hacker-news.firebaseio.com/v0/" + let decoder = JSONDecoder() + init() {} - func fetchTopStories() async -> [Story] { + func fetchStories(feedType: FeedType) async -> [Story] { NotificationCenter.default.post(name: Notification.Name(rawValue: "EmergeMetricStarted"), object: nil, userInfo: [ "metric": "FETCH_STORIES" ]) - let url = URL(string: "https://hacker-news.firebaseio.com/v0/topstories.json")! + + let feedUrl = switch feedType { + case .top: + "topstories.json" + case .new: + "newstories.json" + case .best: + "beststories.json" + case .ask: + "askstories.json" + case .show: + "showstories.json" + } + + let url = URL(string: baseUrl + feedUrl)! do { let (data, response) = try await URLSession.shared.data(from: url) if Flags.isEnabled(.networkDebugger) { NetworkDebugger.printStats(for: response) } - let decoder = JSONDecoder() let storyIds = try decoder.decode([Int64].self, from: data) let items = await fetchItems(ids: Array(storyIds.prefix(20))) diff --git a/ios/HackerNews/Screens/BookmarksScreen.swift b/ios/HackerNews/Screens/BookmarksScreen.swift index 25b7750f..3e6b3c90 100644 --- a/ios/HackerNews/Screens/BookmarksScreen.swift +++ b/ios/HackerNews/Screens/BookmarksScreen.swift @@ -2,7 +2,7 @@ // BookmarksScreen.swift // HackerNews // -// Created by Rikin Marfatia on 11/22/24. +// Created by Rikin Marfatia on 12/3/24. // import Foundation @@ -10,6 +10,10 @@ import SwiftUI struct BookmarksScreen: View { var body: some View { - Text("Hello Bookmarks") + Text("Bookmarks Screen") } } + +#Preview { + BookmarksScreen() +} diff --git a/ios/HackerNews/Screens/FeedPagerTest.swift b/ios/HackerNews/Screens/FeedPagerTest.swift new file mode 100644 index 00000000..d56aeaa7 --- /dev/null +++ b/ios/HackerNews/Screens/FeedPagerTest.swift @@ -0,0 +1,44 @@ +// +// FeedPagerTest.swift +// HackerNews +// +// Created by Rikin Marfatia on 11/25/24. +// + +import Foundation +import SwiftUI + +struct FeedPagerTest: View { + @State private var selection = 0 + let tabs = ["Top", "New"] + var body: some View { + VStack(spacing: 0) { + // Tab Bar + HStack(spacing: 16.0) { + ForEach(Array(tabs.enumerated()), id: \.offset) { index, title in + Button(action: { + withAnimation { + selection = index + } + }) { + Text(title) + .foregroundColor(selection == index ? .blue : .gray) + .scaleEffect(selection == index ? 1.2 : 1.0) + } + } + } + + TabView(selection: $selection) { + ForEach(Array(tabs.enumerated()), id: \.offset) { index, title in + Text(title) + .tag(index) + } + } + .tabViewStyle(.page) + } + } +} + +#Preview { + FeedPagerTest() +} diff --git a/ios/HackerNews/Screens/FullScreentest.swift b/ios/HackerNews/Screens/FullScreentest.swift new file mode 100644 index 00000000..eaab2cbd --- /dev/null +++ b/ios/HackerNews/Screens/FullScreentest.swift @@ -0,0 +1,27 @@ +// +// Setup Screen.swift +// HackerNews +// +// Created by Rikin Marfatia on 11/27/24. +// + +import Foundation +import SwiftUI + +struct FullScreenTest: View { + var body: some View { + VStack { + HStack { + Text("Hello") + Text("Hello") + } + ProgressView() + .progressViewStyle(.circular) + .frame(maxHeight: .infinity) + } + } +} + +#Preview { + FullScreenTest() +} diff --git a/ios/HackerNews/Screens/LoginScreen.swift b/ios/HackerNews/Screens/LoginScreen.swift index 21e2d79c..095bf8fe 100644 --- a/ios/HackerNews/Screens/LoginScreen.swift +++ b/ios/HackerNews/Screens/LoginScreen.swift @@ -23,7 +23,7 @@ struct LoginScreen: View { Button("Login") { appState.performLogin() Task { - await appState.fetchPosts() + await appState.fetchPosts(feedType: .top) } } .buttonStyle(ThemedButtonStyle()) diff --git a/ios/HackerNews/Screens/PostListScreen.swift b/ios/HackerNews/Screens/PostListScreen.swift index 8b3a9e59..a89c3e39 100644 --- a/ios/HackerNews/Screens/PostListScreen.swift +++ b/ios/HackerNews/Screens/PostListScreen.swift @@ -13,57 +13,77 @@ struct PostListScreen: View { @ObservedObject var appState: AppViewModel var body: some View { - Group { - switch appState.storiesState { + VStack { + HStack(spacing: 16) { + ForEach(appState.postListState.feeds, id: \.self) { feedType in + Button(action: { + Task { + await appState.fetchPosts(feedType: feedType) + } + }) { + Text(feedType.title) + .foregroundColor(appState.postListState.selectedFeed == feedType ? .hnOrange : .gray) + .fontWeight(appState.postListState.selectedFeed == feedType ? .bold : .regular) + .font(.title2) + } + } + } + .padding(16) + switch appState.postListState.storiesState { case .notStarted, .loading: ProgressView() .progressViewStyle(CircularProgressViewStyle()) .scaleEffect(2) - case .loaded(let stories): - List(stories, id: \.id) { story in + .frame(maxHeight: .infinity) + case .loaded(let items): + TabView(selection: .constant(0)) { + List(items, id: \.id) { story in let navigationValue: AppViewModel.AppNavigation = { - if let url = story.makeUrl() { - return AppViewModel.AppNavigation.webLink(url: url, title: story.title) - } else { - return AppViewModel.AppNavigation.storyComments(story: story) - } + if let url = story.makeUrl() { + return AppViewModel.AppNavigation.webLink(url: url, title: story.title) + } else { + return AppViewModel.AppNavigation.storyComments(story: story) + } }() - StoryRowV2( - model: appState, - story: story + StoryRow( + model: appState, + story: story ) .background( - NavigationLink( - value: navigationValue, - label: {} - ) - .opacity(0.0) + NavigationLink( + value: navigationValue, + label: {} + ) + .opacity(0.0) ) .listRowBackground(Color.clear) - } - .listStyle(.plain) - } - } - .navigationBarTitle("Hacker News") - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button(action: { - Task { - await appState.fetchPosts() } - }) { - Image(systemName: "arrow.counterclockwise") - .foregroundColor(.white) - } - Button(action: { - appState.performLogout() - }) { - Image(systemName: "rectangle.portrait.and.arrow.right") - .foregroundColor(.white) + .tag(0) + .listStyle(.plain) } + .tabViewStyle(.page) } } +// .navigationBarTitle("Hacker News") +// .toolbar { +// ToolbarItemGroup(placement: .navigationBarTrailing) { +// Button(action: { +// Task { +// await appState.fetchPosts(feedType: .top) +// } +// }) { +// Image(systemName: "arrow.counterclockwise") +// .foregroundColor(.white) +// } +// Button(action: { +// appState.performLogout() +// }) { +// Image(systemName: "rectangle.portrait.and.arrow.right") +// .foregroundColor(.white) +// } +// } +// } } } @@ -75,13 +95,13 @@ struct PostListScreen: View { #Preview("Loading") { let appModel = AppViewModel() appModel.authState = .loggedIn - appModel.storiesState = .loading + appModel.postListState = PostListState(storiesState: .loading) return PostListScreen(appState: appModel) } #Preview("Has posts") { let appModel = AppViewModel() appModel.authState = .loggedIn - appModel.storiesState = .loaded(stories: PreviewHelpers.makeFakeStories()) + appModel.postListState = PostListState(storiesState: .loaded(items: PreviewHelpers.makeFakeStories())) return PostListScreen(appState: appModel) } diff --git a/ios/HackerNews/Screens/SettingsScreen.swift b/ios/HackerNews/Screens/SettingsScreen.swift index 532d8595..20380046 100644 --- a/ios/HackerNews/Screens/SettingsScreen.swift +++ b/ios/HackerNews/Screens/SettingsScreen.swift @@ -2,7 +2,7 @@ // SettingsScreen.swift // HackerNews // -// Created by Rikin Marfatia on 11/22/24. +// Created by Rikin Marfatia on 12/3/24. // import Foundation @@ -10,6 +10,10 @@ import SwiftUI struct SettingsScreen: View { var body: some View { - Text("Hello Settings") + Text("Settings Screen") } } + +#Preview { + SettingsScreen() +} diff --git a/ios/HackerNewsTests/SwiftSnapshotTest.swift b/ios/HackerNewsTests/SwiftSnapshotTest.swift index d08f486c..317db92c 100644 --- a/ios/HackerNewsTests/SwiftSnapshotTest.swift +++ b/ios/HackerNewsTests/SwiftSnapshotTest.swift @@ -62,14 +62,19 @@ final class SwiftSnapshotTest: XCTestCase { // Test loading state let loadingViewModel = AppViewModel() loadingViewModel.authState = .loggedIn - loadingViewModel.storiesState = .loading + loadingViewModel.postListState = PostListState( + storiesState: .loading + ) let loadingView = PostListScreen(appState: loadingViewModel) // Test loaded state with posts let loadedViewModel = AppViewModel() loadedViewModel.authState = .loggedIn - loadedViewModel.storiesState = .loaded( - stories: PreviewHelpers.makeFakeStories()) + loadedViewModel.postListState = PostListState( + storiesState: .loaded( + items: PreviewHelpers.makeFakeStories() + ) + ) let loadedView = PostListScreen(appState: loadedViewModel) let devices = [