| 1 | import 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`). |
| 6 | enum 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 | |
| 11 | extension 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 | |
| 29 | extension 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 | |
| 95 | extension 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. |
| 103 | final 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 | |
| 243 | extension 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. |
| 250 | enum 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 | } |