Skip to content
This repository was archived by the owner on Sep 20, 2023. It is now read-only.

Commit 49c2bec

Browse files
authored
New Bookmarks feature that syncs to iCloud (#2483)
* bookmark migrator that fetches bookmarks * wip building * add tests for migrator and cloud store * working unit tests for new bookmarks * working UI with new bookmarks * new bookmark gql * basic wiring working * load repo default branch and issuesEnabled * fix tests * UI fixes, live syncing with iCloud works * support nav from new bookmarks, clear header * remove swipe from bookmark cells * remove old code * remove tests
1 parent 460403f commit 49c2bec

File tree

64 files changed

+3042
-774
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+3042
-774
lines changed

BookmarkRepoCell.swift

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
//
2+
// BookmarkRepoCell.swift
3+
//
4+
//
5+
// Created by Ryan Nystrom on 11/25/18.
6+
//
7+
8+
import Foundation

Classes/Bookmark/Bookmark.swift

+1-7
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,18 @@ struct Bookmark: Codable, Equatable {
1414
let owner: String
1515
let number: Int
1616
let title: String
17-
let hasIssueEnabled: Bool
18-
let defaultBranch: String
1917

2018
init(type: NotificationType,
2119
name: String,
2220
owner: String,
2321
number: Int = 0,
24-
title: String = "",
25-
hasIssueEnabled: Bool = false,
26-
defaultBranch: String = "master"
22+
title: String = ""
2723
) {
2824
self.type = type
2925
self.name = name
3026
self.owner = owner
3127
self.number = number
3228
self.title = title
33-
self.hasIssueEnabled = hasIssueEnabled
34-
self.defaultBranch = defaultBranch
3529
}
3630
}
3731

Classes/Bookmark/BookmarkCell.swift

+51-30
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import UIKit
1010
import SnapKit
1111
import StyledTextKit
1212

13-
final class BookmarkCell: SwipeSelectableCell {
13+
final class BookmarkCell: SelectableCell {
1414

1515
static let titleInset = UIEdgeInsets(
1616
top: Styles.Sizes.rowSpacing,
@@ -26,24 +26,24 @@ final class BookmarkCell: SwipeSelectableCell {
2626
override init(frame: CGRect) {
2727
super.init(frame: frame)
2828

29+
accessibilityIdentifier = "bookmark-cell"
2930
backgroundColor = .white
3031

3132
contentView.clipsToBounds = true
3233

33-
imageView.contentMode = .scaleAspectFit
34-
imageView.clipsToBounds = true
34+
imageView.contentMode = .center
3535
imageView.tintColor = Styles.Colors.Blue.medium.color
3636
contentView.addSubview(imageView)
3737
imageView.snp.makeConstraints { make in
3838
make.centerY.equalTo(contentView)
39-
make.left.equalTo(Styles.Sizes.rowSpacing)
39+
make.left.equalTo(Styles.Sizes.columnSpacing)
4040
make.size.equalTo(Styles.Sizes.icon)
4141
}
4242

4343
contentView.addSubview(textView)
4444
contentView.addSubview(detailLabel)
4545

46-
addBorder(.bottom, left: Styles.Sizes.gutter)
46+
addBorder(.bottom, left: BookmarkCell.titleInset.left)
4747
}
4848

4949
required init?(coder aDecoder: NSCoder) {
@@ -73,33 +73,54 @@ final class BookmarkCell: SwipeSelectableCell {
7373
}
7474
}
7575

76-
func configure(viewModel: BookmarkViewModel, height: CGFloat) {
77-
imageView.image = viewModel.bookmark.type.icon()?.withRenderingMode(.alwaysTemplate)
78-
textView.configure(with: viewModel.text, width: contentView.bounds.width)
79-
80-
// set "Owner/Repo #123" on the detail label if issue/PR, otherwise clear and collapse it
81-
switch viewModel.bookmark.type {
82-
case .issue, .pullRequest:
83-
let detailString = NSMutableAttributedString(
84-
string: "\(viewModel.bookmark.owner)/\(viewModel.bookmark.name)",
85-
attributes: [
86-
.font: Styles.Text.secondaryBold.preferredFont,
87-
.foregroundColor: Styles.Colors.Gray.light.color
88-
]
89-
)
90-
detailString.append(NSAttributedString(
91-
string: " #\(viewModel.bookmark.number)",
92-
attributes: [
93-
.font: Styles.Text.secondary.preferredFont,
94-
.foregroundColor: Styles.Colors.Gray.light.color
95-
]
96-
))
97-
detailLabel.attributedText = detailString
98-
default:
99-
detailLabel.text = ""
76+
func configure(with model: BookmarkIssueViewModel) {
77+
let imageName: String
78+
if model.state == .merged {
79+
imageName = "git-merge"
80+
} else {
81+
imageName = model.isPullRequest ? "git-pull-request" : "issue-opened"
82+
}
83+
imageView.image = UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate)
84+
85+
let tint: UIColor
86+
switch model.state {
87+
case .merged: tint = Styles.Colors.purple.color
88+
case .open: tint = Styles.Colors.Green.medium.color
89+
case .closed: tint = Styles.Colors.Red.medium.color
90+
case .pending: tint = Styles.Colors.Yellow.medium.color
10091
}
92+
imageView.tintColor = tint
93+
94+
textView.configure(with: model.text, width: contentView.bounds.width)
95+
96+
let detailString = NSMutableAttributedString(
97+
string: "\(model.repo.owner)/\(model.repo.name)",
98+
attributes: [
99+
.font: Styles.Text.secondaryBold.preferredFont,
100+
.foregroundColor: Styles.Colors.Gray.light.color
101+
]
102+
)
103+
detailString.append(NSAttributedString(
104+
string: " #\(model.number)",
105+
attributes: [
106+
.font: Styles.Text.secondary.preferredFont,
107+
.foregroundColor: Styles.Colors.Gray.light.color
108+
]
109+
))
110+
detailLabel.attributedText = detailString
101111
detailLabel.sizeToFit()
112+
}
102113

103-
setNeedsLayout()
114+
func configureRepo(
115+
imageName: String,
116+
text: StyledTextRenderer,
117+
owner: String,
118+
name: String
119+
) {
120+
imageView.image = UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate)
121+
textView.configure(with: text, width: contentView.bounds.width)
122+
// clear the detail for reuse
123+
detailLabel.text = ""
104124
}
125+
105126
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
//
2+
// BookmarkCloudMigrator.swift
3+
// Freetime
4+
//
5+
// Created by Ryan Nystrom on 11/23/18.
6+
// Copyright © 2018 Ryan Nystrom. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
enum BookmarkCloudMigratorClientResult {
12+
case noMigration
13+
case success([String])
14+
case error(Error?)
15+
}
16+
17+
enum BookmarkCloudMigratorClientBookmarks {
18+
case repo(owner: String, name: String)
19+
case issueOrPullRequest(owner: String, name: String, number: Int)
20+
}
21+
22+
protocol BookmarkCloudMigratorClient {
23+
func fetch(
24+
bookmarks: [BookmarkCloudMigratorClientBookmarks],
25+
completion: @escaping (BookmarkCloudMigratorClientResult) -> Void
26+
)
27+
}
28+
29+
final class BookmarkCloudMigrator {
30+
31+
private let bookmarks: [BookmarkCloudMigratorClientBookmarks]
32+
private let client: BookmarkCloudMigratorClient
33+
34+
enum State: Int {
35+
case inProgress
36+
case success
37+
case error
38+
}
39+
40+
private(set) var state: State = .inProgress
41+
private let needsMigrationKey: String
42+
43+
init(username: String, oldBookmarks: [Bookmark], client: BookmarkCloudMigratorClient) {
44+
self.bookmarks = oldBookmarks.compactMap {
45+
switch $0.type {
46+
case .commit, .release, .securityVulnerability: return nil
47+
case .issue, .pullRequest:
48+
return .issueOrPullRequest(owner: $0.owner, name: $0.name, number: $0.number)
49+
case .repo:
50+
return .repo(owner: $0.owner, name: $0.name)
51+
}
52+
}
53+
self.client = client
54+
self.needsMigrationKey = "com.freetime.bookmark-cloud-migrator.has-migrated.\(username)"
55+
}
56+
57+
var needsMigration: Bool {
58+
get {
59+
return state != .success
60+
// dont offer migration if no old bookmarks
61+
&& !bookmarks.isEmpty
62+
// or this device has already performed a sync
63+
&& !UserDefaults.standard.bool(forKey: needsMigrationKey)
64+
}
65+
set {
66+
UserDefaults.standard.set(newValue, forKey: needsMigrationKey)
67+
}
68+
}
69+
70+
func sync(completion: @escaping (BookmarkCloudMigratorClientResult) -> Void) {
71+
guard needsMigration else {
72+
state = .success
73+
completion(.noMigration)
74+
return
75+
}
76+
77+
client.fetch(bookmarks: bookmarks) { [weak self] result in
78+
switch result {
79+
case .success, .noMigration:
80+
self?.needsMigration = false
81+
self?.state = .success
82+
case .error:
83+
self?.state = .error
84+
}
85+
completion(result)
86+
}
87+
}
88+
89+
}
+147
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
//
2+
// BookmarkCloudStore.swift
3+
// Freetime
4+
//
5+
// Created by Ryan Nystrom on 11/23/18.
6+
// Copyright © 2018 Ryan Nystrom. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
protocol BookmarkIDCloudKeyValueStore {
12+
func savedBookmarks(for key: String) -> NSMutableOrderedSet
13+
func set(bookmarks: NSMutableOrderedSet, for key: String)
14+
@discardableResult func synchronize() -> Bool
15+
}
16+
17+
// use in app
18+
extension NSUbiquitousKeyValueStore: BookmarkIDCloudKeyValueStore {
19+
func savedBookmarks(for key: String) -> NSMutableOrderedSet {
20+
if let arr = array(forKey: key) as? [String] {
21+
return NSMutableOrderedSet(array: arr)
22+
}
23+
return NSMutableOrderedSet()
24+
}
25+
26+
func set(bookmarks: NSMutableOrderedSet, for key: String) {
27+
set(bookmarks.array, forKey: key)
28+
}
29+
}
30+
31+
// use for tests
32+
extension UserDefaults: BookmarkIDCloudKeyValueStore {
33+
func savedBookmarks(for key: String) -> NSMutableOrderedSet {
34+
if let arr = array(forKey: key) as? [String] {
35+
return NSMutableOrderedSet(array: arr)
36+
}
37+
return NSMutableOrderedSet()
38+
}
39+
40+
func set(bookmarks: NSMutableOrderedSet, for key: String) {
41+
set(bookmarks.array, forKey: key)
42+
}
43+
}
44+
45+
protocol BookmarkIDCloudStoreListener: class {
46+
func didUpdateBookmarks(in store: BookmarkIDCloudStore)
47+
}
48+
49+
private class BookmarkListenerWrapper {
50+
init(listener: BookmarkIDCloudStoreListener) {
51+
self.listener = listener
52+
}
53+
weak var listener: BookmarkIDCloudStoreListener?
54+
}
55+
56+
final class BookmarkIDCloudStore {
57+
58+
let username: String
59+
60+
private var listeners = [BookmarkListenerWrapper]()
61+
private let iCloudStore: BookmarkIDCloudKeyValueStore
62+
private let key: String
63+
private var storage: NSMutableOrderedSet
64+
65+
init(
66+
username: String,
67+
iCloudStore: BookmarkIDCloudKeyValueStore = NSUbiquitousKeyValueStore()
68+
) {
69+
self.username = username
70+
let key = "bookmarks.\(username)"
71+
self.key = key
72+
self.iCloudStore = iCloudStore
73+
self.storage = iCloudStore.savedBookmarks(for: key)
74+
NotificationCenter.default.addObserver(
75+
self,
76+
selector: #selector(iCloudDidUpdate),
77+
name: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
78+
object: nil
79+
)
80+
}
81+
82+
func contains(graphQLID: String) -> Bool {
83+
assert(Thread.isMainThread)
84+
return storage.contains(graphQLID)
85+
}
86+
87+
func add(graphQLID: String) {
88+
assert(Thread.isMainThread)
89+
guard storage.contains(graphQLID) == false else { return }
90+
storage.add(graphQLID)
91+
save()
92+
enumerateListeners { $0.didUpdateBookmarks(in: self) }
93+
}
94+
95+
func add(graphQLIDs: [String]) {
96+
assert(Thread.isMainThread)
97+
storage.addObjects(from: graphQLIDs)
98+
save()
99+
enumerateListeners { $0.didUpdateBookmarks(in: self) }
100+
}
101+
102+
func remove(graphQLID: String) {
103+
assert(Thread.isMainThread)
104+
guard storage.contains(graphQLID) else { return }
105+
storage.remove(graphQLID)
106+
save()
107+
enumerateListeners { $0.didUpdateBookmarks(in: self) }
108+
}
109+
110+
var ids: [String] {
111+
assert(Thread.isMainThread)
112+
return storage.array as? [String] ?? []
113+
}
114+
115+
func add(listener: BookmarkIDCloudStoreListener) {
116+
assert(Thread.isMainThread)
117+
listeners.append(BookmarkListenerWrapper(listener: listener))
118+
}
119+
120+
func clear() {
121+
assert(Thread.isMainThread)
122+
storage.removeAllObjects()
123+
save()
124+
enumerateListeners { $0.didUpdateBookmarks(in: self) }
125+
}
126+
127+
private func save() {
128+
assert(Thread.isMainThread)
129+
iCloudStore.set(bookmarks: storage, for: key)
130+
iCloudStore.synchronize()
131+
}
132+
133+
private func enumerateListeners(block: (BookmarkIDCloudStoreListener) -> Void) {
134+
listeners.forEach {
135+
if let listener = $0.listener {
136+
block(listener)
137+
}
138+
}
139+
}
140+
141+
@objc func iCloudDidUpdate() {
142+
iCloudStore.synchronize()
143+
storage = iCloudStore.savedBookmarks(for: key)
144+
enumerateListeners { $0.didUpdateBookmarks(in: self) }
145+
}
146+
147+
}

0 commit comments

Comments
 (0)