| 1 | import 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`). |
| 6 | enum Prototype {} |
| 7 | |
| 8 | extension 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 | |
| 75 | extension 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. |
| 83 | final 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 | |
| 225 | extension 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. |
| 232 | enum 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 | } |