Skip to content

Commit 99d31cd

Browse files
Rahkeentrevor-e
andauthored
Adding Bookmarking (#305)
This diff adds the infrastructure for bookmarking. It's leveraging SwiftData, albeit not in the exact way that's recommended since we are using ViewModels for data retrieval and mapping to state. There are still probably some kinks to iron out here. I'm calling a lot of fetches to get the latest version of out bookmarks when ideally I can just observe some changes. --------- Co-authored-by: Trevor Elkins <trevor@emergetools.com>
1 parent 963e760 commit 99d31cd

File tree

12 files changed

+379
-55
lines changed

12 files changed

+379
-55
lines changed

ios/HackerNews.xcodeproj/project.pbxproj

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
EC70E1612D1DD82B00582023 /* HNWebClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC70E1602D1DD82B00582023 /* HNWebClient.swift */; };
4949
EC882BB22D23A8A70065FEC0 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC882BB12D23A8A70065FEC0 /* LoginScreen.swift */; };
5050
ECC0BC8C2D39CDCB00ABB263 /* CommentComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC0BC8B2D39CDCB00ABB263 /* CommentComposer.swift */; };
51+
ECC0BC8F2D3A34A600ABB263 /* Bookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC0BC8E2D3A34A600ABB263 /* Bookmarks.swift */; };
5152
ECCE8F262D03815300349733 /* Pager.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECCE8F252D03815300349733 /* Pager.swift */; };
5253
ECE608562D338B3E008B1618 /* ibm_plex_sans_medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = ECE608532D338B3E008B1618 /* ibm_plex_sans_medium.ttf */; };
5354
ECE608572D338B3E008B1618 /* ibm_plex_sans_bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = ECE608522D338B3E008B1618 /* ibm_plex_sans_bold.ttf */; };
@@ -153,6 +154,7 @@
153154
EC70E1602D1DD82B00582023 /* HNWebClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HNWebClient.swift; sourceTree = "<group>"; };
154155
EC882BB12D23A8A70065FEC0 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreen.swift; sourceTree = "<group>"; };
155156
ECC0BC8B2D39CDCB00ABB263 /* CommentComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentComposer.swift; sourceTree = "<group>"; };
157+
ECC0BC8E2D3A34A600ABB263 /* Bookmarks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmarks.swift; sourceTree = "<group>"; };
156158
ECCE8F252D03815300349733 /* Pager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pager.swift; sourceTree = "<group>"; };
157159
ECE608522D338B3E008B1618 /* ibm_plex_sans_bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = ibm_plex_sans_bold.ttf; sourceTree = "<group>"; };
158160
ECE608532D338B3E008B1618 /* ibm_plex_sans_medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = ibm_plex_sans_medium.ttf; sourceTree = "<group>"; };
@@ -270,6 +272,7 @@
270272
A427057B2A4293B10057E439 /* HackerNews */ = {
271273
isa = PBXGroup;
272274
children = (
275+
ECC0BC8D2D3A348A00ABB263 /* Data */,
273276
A435EF3D2C08F2A9005BF473 /* HackerNews.entitlements */,
274277
A45C2CBB2A5F0A41009BC030 /* Hacker-News-Info.plist */,
275278
1DF161FB2A4364CB001A3F76 /* Components */,
@@ -397,6 +400,14 @@
397400
name = Frameworks;
398401
sourceTree = "<group>";
399402
};
403+
ECC0BC8D2D3A348A00ABB263 /* Data */ = {
404+
isa = PBXGroup;
405+
children = (
406+
ECC0BC8E2D3A34A600ABB263 /* Bookmarks.swift */,
407+
);
408+
path = Data;
409+
sourceTree = "<group>";
410+
};
400411
ECE608492D338A43008B1618 /* Font */ = {
401412
isa = PBXGroup;
402413
children = (
@@ -621,6 +632,7 @@
621632
A47309B62AA7D1F600201376 /* CommentRow.swift in Sources */,
622633
A49933952AA28B6500DED8B1 /* CommentsScreen.swift in Sources */,
623634
A42705A72A42949D0057E439 /* AppViewModel.swift in Sources */,
635+
ECC0BC8F2D3A34A600ABB263 /* Bookmarks.swift in Sources */,
624636
A42705AD2A429D2E0057E439 /* HNApi.swift in Sources */,
625637
ECC0BC8C2D39CDCB00ABB263 /* CommentComposer.swift in Sources */,
626638
A423B0682BAE05FB00267DDB /* NetworkDebugger.swift in Sources */,
@@ -804,7 +816,7 @@
804816
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
805817
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
806818
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
807-
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
819+
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
808820
LD_GENERATE_MAP_FILE = YES;
809821
LD_RUNPATH_SEARCH_PATHS = (
810822
"$(inherited)",
@@ -847,7 +859,7 @@
847859
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
848860
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
849861
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
850-
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
862+
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
851863
LD_GENERATE_MAP_FILE = YES;
852864
LD_RUNPATH_SEARCH_PATHS = (
853865
"$(inherited)",
@@ -875,7 +887,7 @@
875887
CURRENT_PROJECT_VERSION = 1;
876888
DEVELOPMENT_TEAM = 62J2XHNK9T;
877889
GENERATE_INFOPLIST_FILE = YES;
878-
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
890+
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
879891
MARKETING_VERSION = 1.0;
880892
PRODUCT_BUNDLE_IDENTIFIER = "com.emerge.hn.Hacker-NewsTests";
881893
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -896,7 +908,7 @@
896908
CURRENT_PROJECT_VERSION = 1;
897909
DEVELOPMENT_TEAM = 62J2XHNK9T;
898910
GENERATE_INFOPLIST_FILE = YES;
899-
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
911+
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
900912
MARKETING_VERSION = 1.0;
901913
PRODUCT_BUNDLE_IDENTIFIER = "com.emerge.hn.Hacker-NewsTests";
902914
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -916,6 +928,7 @@
916928
CURRENT_PROJECT_VERSION = 1;
917929
DEVELOPMENT_TEAM = 62J2XHNK9T;
918930
GENERATE_INFOPLIST_FILE = YES;
931+
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
919932
MARKETING_VERSION = 1.0;
920933
PRODUCT_BUNDLE_IDENTIFIER = "com.emerge.hn.Hacker-NewsUITests";
921934
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -934,6 +947,7 @@
934947
CURRENT_PROJECT_VERSION = 1;
935948
DEVELOPMENT_TEAM = 62J2XHNK9T;
936949
GENERATE_INFOPLIST_FILE = YES;
950+
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
937951
MARKETING_VERSION = 1.0;
938952
PRODUCT_BUNDLE_IDENTIFIER = "com.emerge.hn.Hacker-NewsUITests";
939953
PRODUCT_NAME = "$(TARGET_NAME)";

ios/HackerNews/Components/StoryRow.swift

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,42 +23,50 @@ struct StoryRow: View {
2323
await model.fetchNextPage()
2424
}
2525
}
26-
case .loaded(let story):
26+
case .loaded(let content):
2727
VStack(alignment: .leading, spacing: 8) {
28-
let author = story.by!
29-
Text("@\(author)")
30-
.font(.custom("IBMPlexMono-Bold", size: 12))
31-
.foregroundColor(.hnOrange)
32-
Text(story.title)
28+
let author = content.author!
29+
HStack {
30+
Text("@\(author)")
31+
.font(.custom("IBMPlexMono-Bold", size: 12))
32+
.foregroundColor(.hnOrange)
33+
Spacer()
34+
if (content.bookmarked) {
35+
Image(systemName: "book.fill")
36+
.font(.system(size: 12))
37+
.foregroundStyle(.hnOrange)
38+
}
39+
}
40+
Text(content.title)
3341
.font(.custom("IBMPlexMono-Bold", size: 16))
3442
HStack(spacing: 16) {
3543
HStack(spacing: 4) {
3644
Image(systemName: "arrow.up")
3745
.font(.system(size: 12))
3846
.foregroundColor(.green)
39-
Text("\(story.score)")
47+
Text("\(content.score)")
4048
.font(.custom("IBMPlexSans-Medium", size: 12))
4149
}
4250
HStack(spacing: 4) {
4351
Image(systemName: "clock")
4452
.font(.system(size: 12))
4553
.foregroundColor(.purple)
46-
Text(story.displayableDate)
54+
Text(content.relativeDate())
4755
.font(.custom("IBMPlexSans-Medium", size: 12))
4856
}
4957
Spacer()
5058
// Comment Button
5159
Button(action: {
52-
print("Pressed comment button for: \(story.id)")
60+
print("Pressed comment button for: \(content.id)")
5361
model.navigationPath.append(
54-
AppViewModel.AppNavigation.storyComments(story: story)
62+
AppViewModel.AppNavigation.storyComments(story: content.toStory())
5563
)
5664
}) {
5765
HStack(spacing: 4) {
5866
Image(systemName: "message.fill")
5967
.font(.system(size: 12))
6068
.foregroundStyle(.blue)
61-
Text("\(story.commentCount)")
69+
Text("\(content.commentCount)")
6270
.font(.custom("IBMPlexSans-Medium", size: 12))
6371
.foregroundStyle(.black)
6472
}
@@ -67,6 +75,27 @@ struct StoryRow: View {
6775
.buttonBorderShape(ButtonBorderShape.capsule)
6876
}
6977
}
78+
.padding(.horizontal, 8)
79+
.onTapGesture {
80+
switch state {
81+
case .loading, .nextPage:
82+
print("Hello")
83+
case .loaded(let content):
84+
let destination: AppViewModel.AppNavigation = if let url = content.makeUrl() {
85+
.webLink(url: url, title: content.title)
86+
} else {
87+
.storyComments(story: content.toStory())
88+
}
89+
print("Navigating to \(destination)")
90+
model.navigationPath.append(destination)
91+
}
92+
}
93+
.onLongPressGesture {
94+
if case .loaded(var content) = state {
95+
content.bookmarked.toggle()
96+
model.toggleBookmark(content)
97+
}
98+
}
7099
}
71100
}
72101
}
@@ -125,7 +154,9 @@ struct StoryRow_Preview: PreviewProvider {
125154
static var previews: some View {
126155
let fakeStory = PreviewHelpers.makeFakeStory(index: 0, descendants: 3, kids: [1, 2, 3])
127156
PreviewVariants {
128-
StoryRow(model: AppViewModel(), state: .loaded(story: fakeStory))
157+
StoryRow(model: AppViewModel(
158+
bookmarkStore: FakeBookmarkDataStore()
159+
), state: .loaded(content: fakeStory.toStoryContent()))
129160
}
130161
}
131162
}

ios/HackerNews/ContentView.swift

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ struct ContentView: View {
1717
.tabItem {
1818
Image(systemName: "newspaper.fill")
1919
}
20-
BookmarksScreen()
20+
BookmarksScreen(model: model)
21+
.onAppear {
22+
model.fetchBookmarks()
23+
}
2124
.tabItem {
2225
Image(systemName: "book")
2326
}
@@ -32,7 +35,7 @@ struct ContentView: View {
3235

3336
struct ContentView_LoggedIn_Loading_Previews: PreviewProvider {
3437
static var previews: some View {
35-
let appModel = AppViewModel()
38+
let appModel = AppViewModel(bookmarkStore: FakeBookmarkDataStore())
3639
appModel.feedState = FeedState(stories: [])
3740
return PreviewVariants {
3841
PreviewHelpers.withNavigationView {
@@ -44,10 +47,10 @@ struct ContentView_LoggedIn_Loading_Previews: PreviewProvider {
4447

4548
struct ContentView_LoggedIn_WithPosts_Previews: PreviewProvider {
4649
static var previews: some View {
47-
let appModel = AppViewModel()
50+
let appModel = AppViewModel(bookmarkStore: FakeBookmarkDataStore())
4851
let fakeStories = PreviewHelpers
4952
.makeFakeStories()
50-
.map { StoryState.loaded(story: $0) }
53+
.map { StoryState.loaded(content: $0.toStoryContent()) }
5154

5255
appModel.authState = .loggedIn
5356
appModel.feedState = FeedState(stories: fakeStories)
@@ -62,7 +65,7 @@ struct ContentView_LoggedIn_WithPosts_Previews: PreviewProvider {
6265

6366
struct ContentView_LoggedIn_EmptyPosts_Previews: PreviewProvider {
6467
static var previews: some View {
65-
let appModel = AppViewModel()
68+
let appModel = AppViewModel(bookmarkStore: FakeBookmarkDataStore())
6669
appModel.feedState = FeedState(stories: [])
6770
return PreviewVariants {
6871
PreviewHelpers.withNavigationView {
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
//
2+
// Bookmarks.swift
3+
// HackerNews
4+
//
5+
// Created by Rikin Marfatia on 1/16/25.
6+
//
7+
8+
import Foundation
9+
import SwiftData
10+
11+
@Model
12+
class Bookmark {
13+
var uid: Int64
14+
var by: String?
15+
var time: Int64
16+
var title: String
17+
var text: String?
18+
var url: String?
19+
var score: Int
20+
var descendants: Int
21+
22+
init(
23+
uid: Int64,
24+
by: String?,
25+
time: Int64,
26+
title: String,
27+
text: String?,
28+
url: String?,
29+
score: Int,
30+
descendants: Int
31+
) {
32+
self.uid = uid
33+
self.by = by
34+
self.time = time
35+
self.title = title
36+
self.text = text
37+
self.url = url
38+
self.score = score
39+
self.descendants = descendants
40+
}
41+
}
42+
43+
extension StoryContent {
44+
func toBookmark() -> Bookmark {
45+
return Bookmark(
46+
uid: id,
47+
by: author,
48+
time: timestamp,
49+
title: title,
50+
text: body,
51+
url: url,
52+
score: score,
53+
descendants: commentCount
54+
)
55+
}
56+
}
57+
58+
protocol BookmarksDataStore {
59+
func fetchBookmarks() -> [Bookmark]
60+
func addBookmark(_ bookmark: Bookmark)
61+
func removeBookmark(with id: Int64)
62+
func containsBookmark(with id: Int64) -> Bool
63+
}
64+
65+
class LiveBookmarksDataStore: BookmarksDataStore {
66+
private let modelContainer: ModelContainer
67+
private let modelContext: ModelContext
68+
69+
@MainActor
70+
static let shared = LiveBookmarksDataStore()
71+
72+
@MainActor
73+
private init() {
74+
self.modelContainer = try! ModelContainer(for: Bookmark.self)
75+
self.modelContext = modelContainer.mainContext
76+
}
77+
78+
func fetchBookmarks() -> [Bookmark] {
79+
do {
80+
return try modelContext.fetch(FetchDescriptor<Bookmark>())
81+
} catch {
82+
fatalError(error.localizedDescription)
83+
}
84+
}
85+
86+
func addBookmark(_ bookmark: Bookmark) {
87+
modelContext.insert(bookmark)
88+
do {
89+
try modelContext.save()
90+
} catch {
91+
fatalError(error.localizedDescription)
92+
}
93+
}
94+
95+
func removeBookmark(with id: Int64) {
96+
do {
97+
try modelContext.delete(
98+
model: Bookmark.self,
99+
where: #Predicate { bookmark in bookmark.uid == id}
100+
)
101+
} catch {
102+
fatalError(error.localizedDescription)
103+
}
104+
}
105+
106+
func containsBookmark(with id: Int64) -> Bool {
107+
do {
108+
let bookmarks = try modelContext.fetch(FetchDescriptor<Bookmark>(predicate: #Predicate { bookmark in bookmark.uid == id}))
109+
return !bookmarks.isEmpty
110+
} catch {
111+
fatalError(error.localizedDescription)
112+
}
113+
}
114+
}
115+
116+
struct FakeBookmarkDataStore: BookmarksDataStore {
117+
func fetchBookmarks() -> [Bookmark] {
118+
return []
119+
}
120+
121+
func addBookmark(_ bookmark: Bookmark) {
122+
}
123+
124+
func removeBookmark(with id: Int64) {
125+
}
126+
127+
func containsBookmark(with id: Int64) -> Bool {
128+
return false
129+
}
130+
}

ios/HackerNews/HNApp.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
import Reaper
99
import Sentry
1010
import SwiftUI
11+
import SwiftData
1112

1213
@main
1314
struct Hacker_NewsApp: App {
14-
@StateObject private var appModel = AppViewModel()
15+
@StateObject private var appModel = AppViewModel(bookmarkStore: LiveBookmarksDataStore.shared)
1516

1617
init() {
1718
UINavigationBar.appearance().backgroundColor = .clear

0 commit comments

Comments
 (0)