Skip to content

Commit 8c5af24

Browse files
authored
fix offline mode. (#72)
1 parent 6452b57 commit 8c5af24

File tree

11 files changed

+135
-63
lines changed

11 files changed

+135
-63
lines changed

Shared/AppDelegate.swift

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import BackgroundTasks
2+
import Foundation
3+
import SwiftUI
4+
import HackerNewsKit
5+
import UserNotifications
6+
7+
class AppDelegate: NSObject, UIApplicationDelegate {
8+
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
9+
BGTaskScheduler.shared.register(forTaskWithIdentifier: Constants.Download.backgroundTaskId,
10+
using: nil) { task in
11+
task.expirationHandler = {
12+
task.setTaskCompleted(success: false)
13+
}
14+
15+
Task {
16+
await OfflineRepository.shared.downloadAllStories()
17+
18+
task.setTaskCompleted(success: true)
19+
}
20+
21+
OfflineRepository.shared.scheduleBackgroundDownload()
22+
}
23+
}
24+
25+
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
26+
UNUserNotificationCenter.current().delegate = self
27+
return true
28+
}
29+
30+
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
31+
// Register SceneDelegate.
32+
let sceneConfig = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
33+
sceneConfig.delegateClass = SceneDelegate.self
34+
return sceneConfig
35+
}
36+
37+
private func configureUserNotifications() {
38+
UNUserNotificationCenter.current().delegate = self
39+
}
40+
}
41+
42+
class SceneDelegate: NSObject, UIWindowSceneDelegate {
43+
func sceneDidEnterBackground(_ scene: UIScene) {
44+
OfflineRepository.shared.scheduleBackgroundDownload()
45+
}
46+
}
47+
48+
extension AppDelegate: UNUserNotificationCenterDelegate {
49+
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
50+
completionHandler([.banner, .sound, .list])
51+
}
52+
53+
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
54+
Task {
55+
let content = response.notification.request.content
56+
if let id = Int(content.targetContentIdentifier ?? ""),
57+
id != 0,
58+
let item = await StoriesRepository.shared.fetchComment(id) {
59+
Router.shared.to(item)
60+
}
61+
}
62+
}
63+
}

Shared/Models/Stores/ItemStore.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ extension ItemView {
2020
private var cancellable: AnyCancellable?
2121

2222
init() {
23-
cancellable = NetworkMonitor.shared.networkStatus.sink { isConnected in
24-
self.isConnectedToNetwork = isConnected
23+
cancellable = NetworkMonitor.shared.networkStatus
24+
.sink { isConnected in
25+
self.isConnectedToNetwork = isConnected ?? false
2526
}
2627
}
2728

Shared/Models/Stores/StoryStore.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ extension HomeView {
1717
private var cancellable: AnyCancellable?
1818

1919
init() {
20-
cancellable = NetworkMonitor.shared.networkStatus.sink { isConnected in
21-
self.isConnectedToNetwork = isConnected
22-
}
20+
cancellable = NetworkMonitor.shared.networkStatus
21+
.sink { isConnected in
22+
self.isConnectedToNetwork = isConnected ?? false
23+
}
2324
}
2425

2526
func fetchStories(status: Status = .inProgress) async {

Shared/Services/OfflineRepository.swift

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import Alamofire
2+
import BackgroundTasks
3+
import Combine
24
import Foundation
35
import SwiftUI
46
import SwiftData
@@ -12,15 +14,35 @@ public class OfflineRepository: ObservableObject {
1214
@Published var isDownloading = false
1315
@Published var completionCount = 0
1416

17+
lazy var lastFetchedAt = {
18+
guard let date = UserDefaults.standard.object(forKey: lastDownloadAtKey) as? Date else { return "" }
19+
let df = DateFormatter()
20+
df.dateFormat = "MM/dd/yyyy HH:mm"
21+
return df.string(from: date)
22+
}()
23+
var isInMemory = false
24+
1525
private let storiesRepository = StoriesRepository.shared
1626
private let container = try! ModelContainer(for: StoryCollection.self, CommentCollection.self)
1727
private let downloadOrder = [StoryType.top, .ask, .best]
28+
private let lastDownloadAtKey = "lastDownloadedAt"
1829
private var stories = [StoryType: [Story]]()
1930
private var comments = [Int: [Comment]]()
31+
private var cancellable: AnyCancellable?
2032

2133
public static let shared: OfflineRepository = .init()
2234

23-
private init() {
35+
init() {
36+
cancellable = NetworkMonitor.shared.networkStatus
37+
.dropFirst()
38+
.sink { isConnected in
39+
if let isConnected = isConnected, !self.isInMemory && !isConnected {
40+
self.loadIntoMemory()
41+
}
42+
}
43+
}
44+
45+
public func loadIntoMemory() {
2446
let context = container.mainContext
2547

2648
// Fetch all cached stories.
@@ -39,13 +61,30 @@ public class OfflineRepository: ObservableObject {
3961
comments[collection.parentId] = collection.comments
4062
}
4163
}
64+
65+
isInMemory = true
66+
}
67+
68+
public func scheduleBackgroundDownload() {
69+
let downloadTask = BGProcessingTaskRequest(identifier: Constants.Download.backgroundTaskId)
70+
// Set earliestBeginDate to be 1 hr from now.
71+
downloadTask.earliestBeginDate = Date(timeIntervalSinceNow: 3600)
72+
downloadTask.requiresNetworkConnectivity = true
73+
downloadTask.requiresExternalPower = true
74+
do {
75+
try BGTaskScheduler.shared.submit(downloadTask)
76+
} catch {
77+
debugPrint("Unable to submit task: \(error.localizedDescription)")
78+
}
4279
}
4380

4481
// MARK: - Story related.
4582

4683
public func downloadAllStories() async -> Void {
4784
isDownloading = true
4885

86+
UserDefaults.standard.set(Date.now, forKey: lastDownloadAtKey)
87+
4988
let context = container.mainContext
5089
var completedStoryId = Set<Int>()
5190

@@ -93,29 +132,11 @@ public class OfflineRepository: ObservableObject {
93132
}
94133

95134
public func fetchAllStories(from storyType: StoryType) -> [Story] {
96-
return stories[storyType] ?? [Story]()
97-
}
98-
99-
public func fetchStoryIds(from storyType: StoryType) async -> [Int] {
100-
return [Int]()
101-
}
102-
103-
public func fetchStoryIds(from storyType: String) async -> [Int] {
104-
return [Int]()
105-
}
106-
107-
public func fetchStory(_ id: Int) async -> Story? {
108-
return nil
109-
// let context = container.mainContext
110-
// var descriptor = FetchDescriptor<StoryCollection>(
111-
// predicate: #Predicate { $0.id == id }
112-
// )
113-
// descriptor.fetchLimit = 1
114-
// if let results = try? context.fetch(descriptor) {
115-
// return results.first?.story
116-
// } else {
117-
// return nil
118-
// }
135+
guard let stories = stories[storyType] else { return [Story]() }
136+
let storiesWithCommentsDownloaded = stories.filter { story in
137+
comments[story.id].isNotNullOrEmpty
138+
}
139+
return storiesWithCommentsDownloaded
119140
}
120141

121142
// MARK: - Comment related.

Shared/Utilities/Constants.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,8 @@ struct Constants {
2222
static let lastFetchedAtKey = "lastFetchedAt"
2323
static let backgroundTaskId = "fetchReplies"
2424
}
25+
26+
struct Download {
27+
static let backgroundTaskId = "download"
28+
}
2529
}

Shared/Utilities/NetworkMonitor.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Network
22
import Combine
33

44
final class NetworkMonitor {
5-
let networkStatus = CurrentValueSubject<Bool, Never>(true)
5+
let networkStatus = CurrentValueSubject<Bool?, Never>(nil)
66
var onWifi = true
77
var onCellular = true
88

Shared/Views/Components/ItemRow.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,9 @@ struct ItemRow: View {
4444
Label("View on Hacker News", systemImage: "safari")
4545
}
4646
} label: {
47-
Image(systemName: "ellipsis")
47+
Label(String(), systemImage: "ellipsis")
4848
.padding(.leading)
4949
.padding(.bottom, 12)
50-
.padding(.trailing)
5150
.foregroundColor(.orange)
5251
}
5352
}

Shared/Views/HomeView.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,12 @@ struct HomeView: View {
120120
Text("\(offlineRepository.completionCount) completed")
121121
} else {
122122
Label("Download all stories", systemImage: "square.and.arrow.down")
123+
if offlineRepository.lastFetchedAt.isNotEmpty {
124+
Text("last downloaded at \(offlineRepository.lastFetchedAt)")
125+
}
123126
}
124127
}
125-
.disabled(offlineRepository.isDownloading)
128+
.disabled(offlineRepository.isDownloading || !storyStore.isConnectedToNetwork)
126129
Divider()
127130
AuthButton(showLoginDialog: $showLoginDialog, showLogoutDialog: $showLogoutDialog)
128131
Button {

Shared/ZCombinatorApp.swift

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import BackgroundTasks
2+
import Foundation
13
import SwiftUI
24
import SwiftData
35
import HackerNewsKit
@@ -9,6 +11,7 @@ struct ZCombinatorApp: App {
911
@Environment(\.scenePhase) private var phase
1012
let auth: Authentication = .shared
1113
let notification: AppNotification = .shared
14+
let offlineRepository: OfflineRepository = .shared
1215

1316
var body: some Scene {
1417
WindowGroup {
@@ -29,31 +32,3 @@ struct ZCombinatorApp: App {
2932
}
3033
}
3134
}
32-
33-
class AppDelegate: NSObject, UIApplicationDelegate {
34-
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
35-
UNUserNotificationCenter.current().delegate = self
36-
return true
37-
}
38-
39-
private func configureUserNotifications() {
40-
UNUserNotificationCenter.current().delegate = self
41-
}
42-
}
43-
44-
extension AppDelegate: UNUserNotificationCenterDelegate {
45-
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
46-
completionHandler([.banner, .sound, .list])
47-
}
48-
49-
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
50-
Task {
51-
let content = response.notification.request.content
52-
if let id = Int(content.targetContentIdentifier ?? ""),
53-
id != 0,
54-
let item = await StoriesRepository.shared.fetchComment(id) {
55-
Router.shared.to(item)
56-
}
57-
}
58-
}
59-
}

ZCombinator--iOS--Info.plist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<dict>
55
<key>BGTaskSchedulerPermittedIdentifiers</key>
66
<array>
7+
<string>download</string>
78
<string>fetchReplies</string>
89
</array>
910
<key>CFBundleURLTypes</key>

0 commit comments

Comments
 (0)