diff --git a/README.md b/README.md index 0f97ee1..5c11314 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,16 @@ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=scooper4711_scrollkeeper&metric=coverage)](https://sonarcloud.io/summary/new_code?id=scooper4711_scrollkeeper) [![GitHub Release](https://img.shields.io/github/v/release/scooper4711/scrollkeeper)](https://github.com/scooper4711/scrollkeeper/releases) [![GitHub Downloads](https://img.shields.io/github/downloads/scooper4711/scrollkeeper/total)](https://github.com/scooper4711/scrollkeeper/releases) -[![Platforms](https://img.shields.io/badge/platforms-macOS%2014%2B%20%7C%20iPadOS%2017%2B-informational)](#requirements) +[![Platforms](https://img.shields.io/badge/platforms-macOS%2014%2B%20%7C%20iPadOS%2017%2B%20%7C%20iOS%2017%2B-informational)](#requirements) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) Have you bought Paizo PDFs for years and now can't find the one you need? On paizo.com your purchases are one long list, every download is another click, a file-per-chapter edition arrives as a zip to unpack, and it is hard to tell which files Paizo has updated since you downloaded them. -Scrollkeeper is an unofficial library manager for your Paizo purchases, on the Mac and the iPad. It fetches the -list of everything you own once and keeps it on your device, with covers, summaries and tags, so you can -search, sort and filter a few thousand titles at once, online or off. Download a title, or several at a time, +Scrollkeeper is an unofficial library manager for your Paizo purchases, on the Mac, the iPad and the iPhone. It +fetches the list of everything you own once and keeps it on your device, with covers, summaries and tags, so you +can search, sort and filter a few thousand titles at once, online or off. Download a title, or several at a time, straight from the list: zips are unpacked for you, and a download Paizo has updated since is marked so you can fetch the new file. @@ -47,7 +47,7 @@ It is free and open source, and it only accesses files that your own account is ## Requirements -macOS 14 or later, or iPadOS 17 or later. +macOS 14 or later, iPadOS 17 or later, or iOS 17 or later on the iPhone. ## Installing a release @@ -136,6 +136,9 @@ the Scrollkeeper-iPad scheme. `make ipad-simulator` builds the app and starts it in an iPad simulator. `make ipad-uitest` runs the UI tests there. +The same app runs on the iPhone. To install it on one, turn on Developer Mode +on the iPhone too and run `IPAD= make ipad-install`. + ## Demo mode Launching the app with the argument `-ScrollkeeperDemo` starts it with an diff --git a/Sources/ScrollkeeperUI/Views/DownloadsButton.swift b/Sources/ScrollkeeperUI/Views/DownloadsButton.swift index e2d5efb..17d1281 100644 --- a/Sources/ScrollkeeperUI/Views/DownloadsButton.swift +++ b/Sources/ScrollkeeperUI/Views/DownloadsButton.swift @@ -33,8 +33,11 @@ struct DownloadsButton: View { .help("Show downloads in progress") .popover(isPresented: $isShowingList, arrowEdge: .bottom) { DownloadsList() - .frame(width: 380) + // Narrower on an iPhone, where the popover has to fit the screen. + .frame(minWidth: 300, idealWidth: 380, maxWidth: 380) .frame(minHeight: 120, maxHeight: 420) + // An iPhone would otherwise turn the short list into a sheet the height of the screen. + .presentationCompactAdaptation(.popover) } } } diff --git a/Sources/ScrollkeeperUI/Views/LibraryPlaceholders.swift b/Sources/ScrollkeeperUI/Views/LibraryPlaceholders.swift new file mode 100644 index 0000000..c466398 --- /dev/null +++ b/Sources/ScrollkeeperUI/Views/LibraryPlaceholders.swift @@ -0,0 +1,65 @@ +import ScrollkeeperKit +import SwiftUI + +/// Shown when the search and filters match nothing. With filters set, it offers to clear them. +struct NoResultsView: View { + @Environment(LibraryStore.self) private var store + + var body: some View { + if store.query.hasFilters { + ContentUnavailableView { + Label("No Results", systemImage: "magnifyingglass") + } description: { + Text(filteredDescription) + } actions: { + Button("Clear the Current Filters") { store.query.clearFilters() } + } + } else { + ContentUnavailableView.search(text: store.query.searchText) + } + } + + private var filteredDescription: String { + let search = store.query.searchText.trimmingCharacters(in: .whitespaces) + return search.isEmpty + ? "No titles match the current filters." + : "Check the spelling or try a new search, or clear the current filters." + } +} + +/// Shown while there is no catalog: either no account yet, or the first sync is running. +struct EmptyLibraryView: View { + @Environment(LibraryStore.self) private var store + /// Opens the settings on the iPad, where they are a sheet of the window. + let openSettings: () -> Void + + var body: some View { + if store.account == .signedOut { + ContentUnavailableView { + Label("No Paizo Account", systemImage: "person.crop.circle.badge.questionmark") + } description: { + Text("Add your Paizo account in Settings to load your library.") + } actions: { + #if os(macOS) + SettingsLink { Text("Open Settings…") } + #else + Button("Open Settings…", action: openSettings) + #endif + } + } else if store.sync.isRunning { + ContentUnavailableView { + Label("Loading Your Library", systemImage: "books.vertical") + } description: { + Text("Listing everything you own takes a few minutes. Titles appear here as they arrive.") + } + } else { + ContentUnavailableView { + Label("No Titles", systemImage: "books.vertical") + } description: { + Text(store.sync.failure.isEmpty ? "Your library is empty." : store.sync.failure) + } actions: { + Button("Load Library") { store.startFullSync() } + } + } + } +} diff --git a/Sources/ScrollkeeperUI/Views/LibraryWindow.swift b/Sources/ScrollkeeperUI/Views/LibraryWindow.swift index 6361fa0..46f8f89 100644 --- a/Sources/ScrollkeeperUI/Views/LibraryWindow.swift +++ b/Sources/ScrollkeeperUI/Views/LibraryWindow.swift @@ -29,9 +29,15 @@ enum ViewMode: String, CaseIterable, Identifiable { struct LibraryWindow: View { @Environment(LibraryStore.self) private var store @Environment(\.scenePhase) private var scenePhase + #if os(iOS) + @Environment(\.horizontalSizeClass) private var sizeClass + #endif @AppStorage("viewMode") private var viewMode = ViewMode.covers @AppStorage("showInspector") private var showInspector = LibraryWindow.showsInspectorAtFirst @State private var selection: LibraryTitle.ID? + /// An iPhone's details sheet, open while a title is chosen. Not kept between launches like `showInspector`, + /// which would open an empty sheet at launch. + @State private var showsPhoneDetails = false /// - Parameter selecting: the title to show in the details at first; used for previews. init(selecting title: LibraryTitle.ID? = nil) { @@ -60,7 +66,7 @@ struct LibraryWindow: View { // iPadOS only shows toolbar items that are attached inside a navigation column. .toolbar { toolbar } .searchable(text: $store.query.searchText, prompt: "Title, author, SKU, series or tag") - .inspector(isPresented: $showInspector) { + .inspector(isPresented: showsOneColumn ? $showsPhoneDetails : $showInspector) { TitleDetailView(item: store.item(id: selection)) .inspectorColumnWidth(min: 300, ideal: 360, max: 520) } @@ -81,10 +87,17 @@ struct LibraryWindow: View { #if !os(macOS) // On the iPad the details cover part of the library, so they open when a title is chosen. .onChange(of: selection) { _, chosen in - if chosen != nil { + guard chosen != nil else { return } + if showsOneColumn { + showsPhoneDetails = true + } else { showInspector = true } } + // Choosing the same title again opens its details again. + .onChange(of: showsPhoneDetails) { _, isShown in + if !isShown { selection = nil } + } #endif .onChange(of: scenePhase) { _, phase in DownloadContinuity.sceneChanged(to: phase, store: store) @@ -123,12 +136,52 @@ struct LibraryWindow: View { switch viewMode { case .covers: CoverGridView(selection: $selection) case .list: TitleListView(selection: $selection) + // A phone is too narrow for the columns, so it shows the list. + case .columns where showsOneColumn: TitleListView(selection: $selection) case .columns: TitleTableView(selection: $selection) } } } + /// Whether the window shows one column at a time, as on an iPhone. Read here, outside the split view, because + /// the columns have a compact width on the iPad too. + private var showsOneColumn: Bool { + #if os(iOS) + sizeClass == .compact + #else + false + #endif + } + @ToolbarContentBuilder private var toolbar: some ToolbarContent { + if showsOneColumn { + phoneToolbar + } else { + fullToolbar + } + } + + /// An iPhone's bar has room for the title and two buttons; the rest is in a menu. Choosing a title opens its + /// details there, so it has no Details button. + @ToolbarContentBuilder private var phoneToolbar: some ToolbarContent { + ToolbarItem { FilterMenu(pulse: filterPulse) } + ToolbarItem { DownloadsButton() } + ToolbarItem { + Menu { + Picker("View", selection: $viewMode) { + ForEach([ViewMode.covers, .list]) { mode in + Label(mode.label, systemImage: mode.symbolName).tag(mode) + } + } + refreshButton + settingsButton + } label: { + Label("More", systemImage: "ellipsis") + } + } + } + + @ToolbarContentBuilder private var fullToolbar: some ToolbarContent { ToolbarItem(placement: Self.viewPickerPlacement) { Picker("View", selection: $viewMode) { ForEach(ViewMode.allCases) { mode in @@ -144,23 +197,9 @@ struct LibraryWindow: View { } ToolbarItem { FilterMenu(pulse: filterPulse) } ToolbarItem { DownloadsButton() } - ToolbarItem { - Button { - store.startRefresh() - } label: { - Label("Check for New Titles", systemImage: "arrow.clockwise") - } - .disabled(store.account == .signedOut || store.sync.isRunning) - .help("Check Paizo for titles added since the last sync") - } + ToolbarItem { refreshButton } #if !os(macOS) - ToolbarItem { - Button { - showSettings = true - } label: { - Label("Settings", systemImage: "gearshape") - } - } + ToolbarItem { settingsButton } #endif ToolbarItem { Button { @@ -172,6 +211,24 @@ struct LibraryWindow: View { } } + private var refreshButton: some View { + Button { + store.startRefresh() + } label: { + Label("Check for New Titles", systemImage: "arrow.clockwise") + } + .disabled(store.account == .signedOut || store.sync.isRunning) + .help("Check Paizo for titles added since the last sync") + } + + private var settingsButton: some View { + Button { + showSettings = true + } label: { + Label("Settings", systemImage: "gearshape") + } + } + private var title: String { switch store.query.scope { case .all: "All Titles" @@ -187,66 +244,3 @@ struct LibraryWindow: View { return shown == store.items.count ? "\(shown) titles" : "\(shown) of \(store.items.count) titles" } } - -/// Shown when the search and filters match nothing. With filters set, it offers to clear them. -struct NoResultsView: View { - @Environment(LibraryStore.self) private var store - - var body: some View { - if store.query.hasFilters { - ContentUnavailableView { - Label("No Results", systemImage: "magnifyingglass") - } description: { - Text(filteredDescription) - } actions: { - Button("Clear the Current Filters") { store.query.clearFilters() } - } - } else { - ContentUnavailableView.search(text: store.query.searchText) - } - } - - private var filteredDescription: String { - let search = store.query.searchText.trimmingCharacters(in: .whitespaces) - return search.isEmpty - ? "No titles match the current filters." - : "Check the spelling or try a new search, or clear the current filters." - } -} - -/// Shown while there is no catalog: either no account yet, or the first sync is running. -struct EmptyLibraryView: View { - @Environment(LibraryStore.self) private var store - /// Opens the settings on the iPad, where they are a sheet of the window. - let openSettings: () -> Void - - var body: some View { - if store.account == .signedOut { - ContentUnavailableView { - Label("No Paizo Account", systemImage: "person.crop.circle.badge.questionmark") - } description: { - Text("Add your Paizo account in Settings to load your library.") - } actions: { - #if os(macOS) - SettingsLink { Text("Open Settings…") } - #else - Button("Open Settings…", action: openSettings) - #endif - } - } else if store.sync.isRunning { - ContentUnavailableView { - Label("Loading Your Library", systemImage: "books.vertical") - } description: { - Text("Listing everything you own takes a few minutes. Titles appear here as they arrive.") - } - } else { - ContentUnavailableView { - Label("No Titles", systemImage: "books.vertical") - } description: { - Text(store.sync.failure.isEmpty ? "Your library is empty." : store.sync.failure) - } actions: { - Button("Load Library") { store.startFullSync() } - } - } - } -} diff --git a/Sources/ScrollkeeperUI/Views/SettingsView.swift b/Sources/ScrollkeeperUI/Views/SettingsView.swift index 7b2fc07..6304da5 100644 --- a/Sources/ScrollkeeperUI/Views/SettingsView.swift +++ b/Sources/ScrollkeeperUI/Views/SettingsView.swift @@ -125,7 +125,8 @@ private struct DownloadSettings: View { Button("Choose…") { chooseFolder() } } #else - LabeledContent("Folder", value: "Files › On My iPad › Scrollkeeper") + // Files names the device's own storage after its kind: On My iPad, On My iPhone. + LabeledContent("Folder", value: "Files › On My \(UIDevice.current.model) › Scrollkeeper") Button(PlatformText.showInFileBrowser) { showFolder() } #endif } header: { @@ -155,7 +156,7 @@ private struct DownloadSettings: View { #if os(macOS) "Files already downloaded stay where they are; move them yourself if you change the folder." #else - "Downloads are kept on this iPad and also appear in the Files app." + "Downloads are kept on this \(UIDevice.current.model) and also appear in the Files app." #endif } diff --git a/docs/specs/library-manager/design.md b/docs/specs/library-manager/design.md index d5edbaf..bb5c744 100644 --- a/docs/specs/library-manager/design.md +++ b/docs/specs/library-manager/design.md @@ -41,6 +41,15 @@ no command-line tools to call. | File tags | Finder tags, both directions | none; tags live in the app | | Settings | Settings window | sheet opened from the toolbar | +On the iPhone (`TARGETED_DEVICE_FAMILY` 1,2) the split view collapses to one +column at a time. `LibraryWindow` reads the horizontal size class itself, outside +the split view, because the columns are compact on the iPad too, and on a +compact window it builds `phoneToolbar` (filters, downloads, a menu with the +view, Check for New Titles and Settings), shows the list for the columns view, +and binds the inspector to an unsaved `showsPhoneDetails`: the saved +`showInspector` would open an empty sheet at launch. The downloads popover keeps +`.presentationCompactAdaptation(.popover)`. + ## How the Paizo library works These facts were established by observing the web library. None of it is a diff --git a/docs/specs/library-manager/requirements.md b/docs/specs/library-manager/requirements.md index 884b9db..f0d60bc 100644 --- a/docs/specs/library-manager/requirements.md +++ b/docs/specs/library-manager/requirements.md @@ -223,6 +223,13 @@ once, online or off. 10.7 Settings are reached from a button in the toolbar. 10.8 The Mac and the iPad each sync with Paizo on their own; catalog, tags and downloads are not shared between devices. +10.9 The iPad app runs on the iPhone too (iOS 17 or later), one screen at a + time: the scopes, then the library. The bar holds the title, the + filters, the downloads and a menu with the view (covers or list), Check + for New Titles and Settings. The columns view needs more width than a + phone has, so the list shows instead. Choosing a title opens its details + as a sheet; closing it clears the choice, so the same title opens again. + The downloads list stays a popover. ## 11. Paizo's Community Use Policy diff --git a/ios/Scrollkeeper.xcodeproj/project.pbxproj b/ios/Scrollkeeper.xcodeproj/project.pbxproj index 938df7c..57d35ab 100644 --- a/ios/Scrollkeeper.xcodeproj/project.pbxproj +++ b/ios/Scrollkeeper.xcodeproj/project.pbxproj @@ -260,6 +260,7 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -270,7 +271,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 2; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -289,6 +290,7 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -299,7 +301,7 @@ SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 2; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; @@ -316,7 +318,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 2; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = "Scrollkeeper-iPad"; }; name = Debug; @@ -334,7 +336,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 2; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = "Scrollkeeper-iPad"; }; name = Release; diff --git a/ios/UITests/PhoneLayoutUITests.swift b/ios/UITests/PhoneLayoutUITests.swift new file mode 100644 index 0000000..ed5d535 --- /dev/null +++ b/ios/UITests/PhoneLayoutUITests.swift @@ -0,0 +1,53 @@ +import XCTest + +/// On an iPhone the bar has the filters, the downloads and a menu with the rest; a title opens its details. +final class PhoneLayoutUITests: XCTestCase { + private var app: XCUIApplication! + + override func setUpWithError() throws { + try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .phone, "Only an iPhone has the narrow bar.") + continueAfterFailure = false + } + + /// Launched without a view mode, since a launch argument would outrank the one the menu saves. + @MainActor + func testTheMenuSwitchesBetweenTheCoversAndTheList() { + launchDemo(viewMode: nil) + + choose("Covers") + let row = app.cells.firstMatch + XCTAssertTrue(row.waitForNonExistence(timeout: 5), "the covers are not a list") + choose("List") + XCTAssertTrue(row.waitForExistence(timeout: 5), app.debugDescription) + } + + @MainActor + func testTheColumnsShowAsTheList() { + launchDemo(viewMode: "columns") + XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 5), app.debugDescription) + } + + @MainActor + func testChoosingATitleOpensItsDetails() { + launchDemo(viewMode: "covers") + app.staticTexts["Sample Adventure: The Lighthouse"].firstMatch.tap() + XCTAssertTrue(app.buttons["View on paizo.com"].waitForExistence(timeout: 5), app.debugDescription) + } + + @MainActor + private func launchDemo(viewMode: String?) { + app = XCUIApplication() + app.launchArguments = ["-ScrollkeeperDemo"] + (viewMode.map { ["-viewMode", $0] } ?? []) + app.launch() + XCTAssertTrue(app.staticTexts["Sample Adventure: The Lighthouse"].waitForExistence(timeout: 30), + "library did not load") + } + + @MainActor + private func choose(_ viewMode: String) { + app.buttons["More"].firstMatch.tap() + let item = app.buttons[viewMode].firstMatch + XCTAssertTrue(item.waitForExistence(timeout: 5), app.debugDescription) + item.tap() + } +}