1import UIKit
2
3/// Design prototypes, off unless a launch argument turns one on, as
4/// `-SnowboundWelcome YES`, `-SnowboundSectionStrip YES` or `-SnowboundQuickNote YES`
5/// (Xcode's scheme, `simctl launch`, or `devicectl … --arguments`).
6enum Prototype {}
7
8extension Prototype {
9 /// First launch: a welcome where the list would be empty, the first notebook found opened
10 /// at its first page, and the name asked at the first edit rather than the first open.
11 static var welcome: Bool { UserDefaults.standard.bool(forKey: "SnowboundWelcome") }
12
13 /// The notebook list's welcome while there are no notebooks, nil once there are or while
14 /// iCloud Drive is still being looked up.
15 static func welcome(
16 new: @escaping () -> Void, openFolder: @escaping () -> Void, connect: @escaping () -> Void
17 ) -> UIContentUnavailableConfiguration? {
18 guard Notebooks.all.isEmpty, !ICloud.signedIn || ICloud.documents != nil else { return nil }
19 var welcome = UIContentUnavailableConfiguration.empty()
20 welcome.image = .notebook(nil, side: 64)
21 welcome.text = "No Notebooks Yet"
22 welcome.secondaryText =
23 ICloud.documents != nil
24 ? "New notebooks go in iCloud Drive, where OneNote and your other devices can open them."
25 : "New notebooks stay on this iPhone. Turn on iCloud Drive to use them on all your devices."
26 var button = UIButton.Configuration.borderedProminent()
27 button.title = "New Notebook"
28 welcome.button = button
29 welcome.buttonProperties.primaryAction = UIAction { _ in new() }
30 var open = UIButton.Configuration.plain()
31 open.title = "Open Notebook"
32 welcome.secondaryButton = open
33 var elsewhere = [
34 UIAction(title: "Open Folder", image: UIImage(systemName: "folder")) { _ in openFolder() },
35 UIAction(title: "Connect to Server", image: UIImage(systemName: "server.rack")) { _ in connect() },
36 ]
37 if !ICloud.signedIn {
38 elsewhere.append(
39 UIAction(title: "Turn On iCloud Drive", image: UIImage(systemName: "icloud")) { _ in
40 if let settings = URL(string: UIApplication.openSettingsURLString) {
41 UIApplication.shared.open(settings)
42 }
43 })
44 }
45 welcome.secondaryButtonProperties.menu = UIMenu(children: elsewhere)
46 return welcome
47 }
48
49 /// Asks for the name edits are stored under, then runs `then`.
50 static func askName(from controller: UIViewController, then: @escaping () -> Void) {
51 let alert = UIAlertController(
52 title: "Your Name", message: "OneNote shows it beside the pages and changes you make.",
53 preferredStyle: .alert)
54 let save = UIAlertAction(title: "Continue", style: .default) { [weak alert] _ in
55 Author.name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces)
56 then()
57 }
58 save.isEnabled = false
59 alert.addTextField { field in
60 field.placeholder = "Name"
61 field.textContentType = .name
62 field.autocapitalizationType = .words
63 field.addAction(
64 UIAction { [weak field, weak save] _ in
65 save?.isEnabled = !(field?.text ?? "").trimmingCharacters(in: .whitespaces).isEmpty
66 }, for: .editingChanged)
67 }
68 alert.addAction(UIAlertAction(title: "Not Now", style: .cancel))
69 alert.addAction(save)
70 alert.preferredAction = save
71 (controller.presentedViewController ?? controller).present(alert, animated: true)
72 }
73}
74
75extension Prototype {
76 /// Pages list: the notebook's sections as OneNote's coloured tabs over the pages; a tab or
77 /// a swipe across the list changes section.
78 static var sectionStrip: Bool { UserDefaults.standard.bool(forKey: "SnowboundSectionStrip") }
79}
80
81/// A notebook's sections as a row of coloured tabs, the open one raised; a section group is a
82/// tab whose menu holds its sections.
83final class SectionStrip: UIView {
84 private let scroll = UIScrollView()
85 private let row = UIStackView()
86 private let rule = UIView()
87 private(set) var notebook: Notebook?
88 private var selected = ""
89 private let onPick: (Tab, Notebook) -> Void
90
91 init(onPick: @escaping (Tab, Notebook) -> Void) {
92 self.onPick = onPick
93 super.init(frame: CGRect(x: 0, y: 0, width: 320, height: 50))
94 backgroundColor = .systemBackground
95 scroll.showsHorizontalScrollIndicator = false
96 row.axis = .horizontal
97 row.spacing = 2
98 row.alignment = .bottom
99 for view in [scroll, rule] as [UIView] {
100 view.translatesAutoresizingMaskIntoConstraints = false
101 addSubview(view)
102 }
103 row.translatesAutoresizingMaskIntoConstraints = false
104 scroll.addSubview(row)
105 NSLayoutConstraint.activate([
106 scroll.leadingAnchor.constraint(equalTo: leadingAnchor),
107 scroll.trailingAnchor.constraint(equalTo: trailingAnchor),
108 scroll.topAnchor.constraint(equalTo: topAnchor, constant: 6),
109 scroll.bottomAnchor.constraint(equalTo: rule.topAnchor),
110 rule.leadingAnchor.constraint(equalTo: leadingAnchor),
111 rule.trailingAnchor.constraint(equalTo: trailingAnchor),
112 rule.bottomAnchor.constraint(equalTo: bottomAnchor),
113 rule.heightAnchor.constraint(equalToConstant: 3),
114 row.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 12),
115 row.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -12),
116 row.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
117 row.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
118 row.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
119 ])
120 }
121
122 required init?(coder: NSCoder) { fatalError() }
123
124 /// The readable sections in the notebook's order, groups' sections where the group is.
125 private var readable: [Tab] { notebook?.tabs.filter(\.readable) ?? [] }
126
127 func show(_ notebook: Notebook, selected path: String) {
128 let first = self.notebook == nil
129 self.notebook = notebook
130 select(path)
131 #if DEBUG
132 // `SNOWBOUND_STRIP_TOUR=N` steps N sections on, one each 1.5 s, for recordings.
133 if first, let steps = ProcessInfo.processInfo.environment["SNOWBOUND_STRIP_TOUR"].flatMap(Int.init) {
134 for step in 1...max(1, steps) {
135 DispatchQueue.main.asyncAfter(deadline: .now() + 1.5 * Double(step)) { [weak self] in
136 if let tab = self?.neighbour(1) { self?.pick(tab) }
137 }
138 }
139 }
140 #endif
141 }
142
143 func select(_ path: String) {
144 selected = path
145 guard let notebook else { return }
146 for view in row.arrangedSubviews { view.removeFromSuperview() }
147 var groups: Set<String> = []
148 for tab in notebook.tabs where tab.readable {
149 let group = tab.group.split(separator: "/").first.map(String.init)
150 if let group {
151 guard groups.insert(group).inserted else { continue }
152 let inside = notebook.tabs.filter {
153 $0.readable && ($0.group == group || $0.group.hasPrefix(group + "/"))
154 }
155 let current = inside.first { $0.path == path }
156 row.addArrangedSubview(
157 button(current?.name ?? group, color: current?.uiColor ?? .systemGray, raised: current != nil,
158 folder: true, menu: UIMenu(title: group, children: inside.map { tab in
159 UIAction(title: tab.name, image: swatch(tab.uiColor),
160 state: tab.path == path ? .on : .off) { [weak self] _ in self?.pick(tab) }
161 })))
162 } else {
163 let tab = tab
164 let button = button(tab.name, color: tab.uiColor, raised: tab.path == path, folder: false, menu: nil)
165 button.addAction(UIAction { [weak self] _ in self?.pick(tab) }, for: .primaryActionTriggered)
166 row.addArrangedSubview(button)
167 }
168 }
169 rule.backgroundColor = notebook.tabs.first { $0.path == path }?.uiColor ?? .separator
170 layoutIfNeeded()
171 if let raised = row.arrangedSubviews.first(where: { $0.tag == 1 }) {
172 scroll.scrollRectToVisible(raised.frame.insetBy(dx: -40, dy: 0), animated: false)
173 }
174 }
175
176 /// The readable section `step` places after the open one, if any.
177 func neighbour(_ step: Int) -> Tab? {
178 let tabs = readable
179 guard let at = tabs.firstIndex(where: { $0.path == selected }), tabs.indices.contains(at + step) else {
180 return nil
181 }
182 return tabs[at + step]
183 }
184
185 func pick(_ tab: Tab) {
186 guard let notebook else { return }
187 select(tab.path)
188 onPick(tab, notebook)
189 }
190
191 private func button(_ title: String, color: UIColor, raised: Bool, folder: Bool, menu: UIMenu?) -> UIButton {
192 var configuration = UIButton.Configuration.filled()
193 configuration.title = title
194 configuration.image = folder ? UIImage(systemName: "folder") : nil
195 configuration.imagePadding = 4
196 configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(textStyle: .caption1)
197 configuration.baseBackgroundColor = raised ? color : color.withAlphaComponent(0.28)
198 configuration.baseForegroundColor = raised ? .white : .label
199 configuration.background.cornerRadius = 9
200 configuration.contentInsets = NSDirectionalEdgeInsets(
201 top: raised ? 11 : 8, leading: 14, bottom: raised ? 9 : 7, trailing: 14)
202 configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer {
203 var attributes = $0
204 attributes.font = .preferredFont(forTextStyle: .subheadline).withTraits(raised ? .traitBold : [])
205 return attributes
206 }
207 let button = UIButton(configuration: configuration)
208 // Tabs: only the top corners round, the open one meeting the rule beneath.
209 button.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
210 button.layer.cornerRadius = 9
211 button.clipsToBounds = true
212 button.tag = raised ? 1 : 0
213 if let menu {
214 button.menu = menu
215 button.showsMenuAsPrimaryAction = true
216 }
217 return button
218 }
219
220 private func swatch(_ color: UIColor) -> UIImage? {
221 UIImage(systemName: "rectangle.portrait.fill")?.withTintColor(color, renderingMode: .alwaysOriginal)
222 }
223}
224
225extension Prototype {
226 /// The notebook list's bottom corner: Quick Note, a new page in a section chosen once.
227 static var quickNote: Bool { UserDefaults.standard.bool(forKey: "SnowboundQuickNote") }
228}
229
230/// OneNote's Quick Notes, which go to its Unfiled Notes section: a page made from the notebook
231/// list goes to the section picked the first time.
232enum QuickNote {
233 private struct Place: Codable, Equatable {
234 let notebook: String
235 let section: String
236 }
237
238 private static let key = "quickNoteSection"
239
240 private static var place: Place? {
241 get { UserDefaults.standard.data(forKey: key).flatMap { try? JSONDecoder().decode(Place.self, from: $0) } }
242 set { UserDefaults.standard.set(try? JSONEncoder().encode(newValue), forKey: key) }
243 }
244
245 /// The chosen section, while its notebook lists it.
246 private static var target: (Tab, Notebook)? {
247 guard let place, let notebook = Notebooks.all.first(where: { $0.id == place.notebook }),
248 let tab = notebook.tabs.first(where: { $0.path == place.section && $0.readable })
249 else { return nil }
250 return (tab, notebook)
251 }
252
253 /// Makes a page in the chosen section and opens it, its title ready for typing; until a
254 /// section is chosen, a tap asks where Quick Notes go.
255 static func item() -> UIBarButtonItem {
256 let item = UIBarButtonItem(title: "Quick Note", image: UIImage(systemName: "square.and.pencil"))
257 let create = UIAction(title: "Quick Note") { _ in
258 guard let (tab, notebook) = target else {
259 scene?.window?.rootViewController?.alert(
260 "Can’t Open the Quick Notes Section", "Touch and hold Quick Note to choose another section.")
261 return
262 }
263 write(in: tab, of: notebook)
264 }
265 item.primaryAction = place == nil ? nil : create
266 item.menu = UIMenu(children: [
267 UIDeferredMenuElement.uncached { [weak item] provide in
268 let notebooks = Notebooks.all.filter { $0.tabs.contains(where: \.readable) }
269 let choices = notebooks.map { notebook in
270 UIMenu(
271 title: notebook.name, image: .notebook(notebook.color),
272 children: notebook.tabs.filter(\.readable).map { tab in
273 let chosen = Place(notebook: notebook.id, section: tab.path)
274 let action = UIAction(
275 title: tab.name,
276 image: UIImage(systemName: "rectangle.portrait.fill")?
277 .withTintColor(tab.uiColor, renderingMode: .alwaysOriginal)
278 ) { _ in
279 let first = item?.primaryAction == nil
280 place = chosen
281 item?.primaryAction = create
282 if first { write(in: tab, of: notebook) }
283 }
284 action.state = place == chosen ? .on : .off
285 return action
286 })
287 }
288 provide([UIMenu(title: "Quick Notes Go To", options: .displayInline, children: choices)])
289 }
290 ])
291 return item
292 }
293
294 private static var scene: SceneDelegate? {
295 UIApplication.shared.connectedScenes.lazy.compactMap { $0.delegate as? SceneDelegate }.first
296 }
297
298 private static func write(in tab: Tab, of notebook: Notebook) {
299 scene?.open(tab, of: notebook) { _ in scene?.newPage() }
300 }
301}