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