Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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=<the iPhone's name> make ipad-install`.

## Demo mode

Launching the app with the argument `-ScrollkeeperDemo` starts it with an
Expand Down
5 changes: 4 additions & 1 deletion Sources/ScrollkeeperUI/Views/DownloadsButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
65 changes: 65 additions & 0 deletions Sources/ScrollkeeperUI/Views/LibraryPlaceholders.swift
Original file line number Diff line number Diff line change
@@ -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() }
}
}
}
}
156 changes: 75 additions & 81 deletions Sources/ScrollkeeperUI/Views/LibraryWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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"
Expand All @@ -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() }
}
}
}
}
5 changes: 3 additions & 2 deletions Sources/ScrollkeeperUI/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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
}

Expand Down
9 changes: 9 additions & 0 deletions docs/specs/library-manager/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/specs/library-manager/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading