Skip to content

Commit c5f5e08

Browse files
authored
feat: improve epub reader overlays (#709)
## Summary Add a `none` page transition option for EPUB paged reading and thread the transition flag through the native paged readers. Keep the footer native, but drive it from `totalProgression` so it shows only a progress bar instead of fabricated global page numbers. Also expose the new reader overlay preferences in settings and update contributor guidance so SwiftUI, UIKit, and AppKit can be chosen based on platform fit rather than a hard SwiftUI bias. ## Scope - EPUB paged transition options, including a no-animation mode - Reader overlay preferences for status bar visibility and footer progress - Native EPUB footer progress bar on iOS and macOS - Localization updates and AGENTS guidance cleanup
1 parent 99bf65e commit c5f5e08

16 files changed

Lines changed: 724 additions & 33 deletions

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ Test with:
163163

164164
### Tech Stack
165165

166-
- **UI**: SwiftUI over UIKit/AppKit, use UIKit/AppKit as less as possible.
166+
- **UI**: SwiftUI, UIKit, and AppKit are all acceptable. Choose the most maintainable and platform-appropriate approach per feature.
167167
- **State**: `@Observable` pattern (not `ObservableObject`)
168168
- **Persistence**: SwiftData for profiles/libraries/fonts/series/books/collections/read lists/dashboard caches, UserDefaults via `AppConfig`
169169
- **Networking**: Centralized `APIClient` with feature-specific services
@@ -339,15 +339,15 @@ KMReader/
339339

340340
1. **Comments**: Minimal, in English only
341341
2. **Commit messages**: Concise, clear, semantic format, in English
342-
3. **SwiftUI over UIKit/AppKit**: Prefer SwiftUI exclusively
342+
3. **UI framework choice**: SwiftUI, UIKit, and AppKit may all be used. Pick the approach that best fits the feature, platform APIs, and maintainability.
343343
4. **No inline Binding**: Avoid inline Binding usage
344344
5. **No confirmationDialog**: Do not use confirmationDialog
345345
6. **One type per file**: Every struct or class in a separate file
346346
7. **@Observable over ObservableObject**: Use @Observable pattern for view models
347347
8. **@AppStorage over UserDefaults**: In views use @AppStorage; elsewhere use AppConfig, UserDefaults is forbidden in files except AppConfig.swift
348348
9. **Computed properties in view bodies**: Avoid stored variables in view bodies
349349
10. **Platform differences**: Use `PlatformHelper` and `#if os(...)` blocks
350-
11. **Direction rule (UI bridging)**: Allow `SwiftUI -> UIKit/AppKit`, but do not use `UIKit/AppKit -> SwiftUI` (`UIHostingController`/`NSHostingController`) for feature screens/components, because `UIHostingController`/`NSHostingController` does not inherit required SwiftUI environment values in this project.
350+
11. **UI bridging discipline**: Interop between SwiftUI and UIKit/AppKit is allowed in either direction. Be explicit about dependency injection and verify environment/data propagation across hosting boundaries instead of assuming it will behave correctly.
351351
12. **Object environment safety**: Do not use non-optional object-style environment dependencies (`@Environment(SomeType.self)`, `@EnvironmentObject`) in app code. Treat them as banned patterns. Pass object dependencies explicitly via initializers, context structs, or action closures. If environment lookup is still required, use a non-object custom `EnvironmentKey` or an optional lookup with controlled fallback/logging instead of crashing.
352352
13. **No unchecked/unsafe APIs**: Do not use `@unchecked Sendable`, `nonisolated(unsafe)`, `unsafeBitCast`, or other `unsafe*` escape hatches in app code. Prefer safe ownership, actor boundaries, copying, or explicit wrappers. If a low-level API appears to require them, stop and redesign instead of introducing them.
353353
14. **Strongly avoid patch-style fixes for structural problems**: When the current abstraction or ownership boundary is wrong, do not preserve it by stacking flags, delays, version counters, bridge layers, or special cases just to keep the diff small. Prefer the larger refactor that moves the code toward the final stable architecture.

KMReader.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@
438438
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements";
439439
CODE_SIGN_IDENTITY = "Apple Development";
440440
CODE_SIGN_STYLE = Automatic;
441-
CURRENT_PROJECT_VERSION = 388;
441+
CURRENT_PROJECT_VERSION = 389;
442442
DEVELOPMENT_TEAM = M777UHWZA4;
443443
ENABLE_APP_SANDBOX = YES;
444444
ENABLE_HARDENED_RUNTIME = YES;
@@ -496,7 +496,7 @@
496496
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements";
497497
CODE_SIGN_IDENTITY = "Apple Distribution";
498498
CODE_SIGN_STYLE = Manual;
499-
CURRENT_PROJECT_VERSION = 388;
499+
CURRENT_PROJECT_VERSION = 389;
500500
DEVELOPMENT_TEAM = "";
501501
"DEVELOPMENT_TEAM[sdk=appletvos*]" = M777UHWZA4;
502502
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4;
@@ -556,7 +556,7 @@
556556
CODE_SIGN_ENTITLEMENTS = KMReaderWidgets/KMReaderWidgets.entitlements;
557557
CODE_SIGN_IDENTITY = "Apple Development";
558558
CODE_SIGN_STYLE = Automatic;
559-
CURRENT_PROJECT_VERSION = 388;
559+
CURRENT_PROJECT_VERSION = 389;
560560
DEVELOPMENT_TEAM = M777UHWZA4;
561561
GENERATE_INFOPLIST_FILE = YES;
562562
INFOPLIST_FILE = KMReaderWidgets/Info.plist;
@@ -590,7 +590,7 @@
590590
CODE_SIGN_IDENTITY = "Apple Distribution";
591591
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
592592
CODE_SIGN_STYLE = Manual;
593-
CURRENT_PROJECT_VERSION = 388;
593+
CURRENT_PROJECT_VERSION = 389;
594594
DEVELOPMENT_TEAM = "";
595595
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4;
596596
GENERATE_INFOPLIST_FILE = YES;

KMReader/Core/Storage/AppConfig.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,30 @@ enum AppConfig {
782782
}
783783
}
784784

785+
static nonisolated var epubShowsStatusBarWhileReading: Bool {
786+
get {
787+
if UserDefaults.standard.object(forKey: "epubShowsStatusBarWhileReading") != nil {
788+
return UserDefaults.standard.bool(forKey: "epubShowsStatusBarWhileReading")
789+
}
790+
return false
791+
}
792+
set {
793+
UserDefaults.standard.set(newValue, forKey: "epubShowsStatusBarWhileReading")
794+
}
795+
}
796+
797+
static nonisolated var epubShowsProgressFooter: Bool {
798+
get {
799+
if UserDefaults.standard.object(forKey: "epubShowsProgressFooter") != nil {
800+
return UserDefaults.standard.bool(forKey: "epubShowsProgressFooter")
801+
}
802+
return false
803+
}
804+
set {
805+
UserDefaults.standard.set(newValue, forKey: "epubShowsProgressFooter")
806+
}
807+
}
808+
785809
// MARK: - Dashboard
786810
static nonisolated var dashboard: DashboardConfiguration {
787811
get {

KMReader/Features/Reader/Models/PageTransitionStyle.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import Foundation
77

88
enum PageTransitionStyle: String, CaseIterable, Hashable {
9+
case none = "none"
910
case scroll = "scroll"
1011
case cover = "cover"
1112
case pageCurl = "pageCurl"
@@ -19,8 +20,17 @@ enum PageTransitionStyle: String, CaseIterable, Hashable {
1920
#endif
2021
}
2122

23+
static var epubAvailableCases: [PageTransitionStyle] {
24+
#if os(iOS)
25+
return [.none, .scroll, .cover, .pageCurl]
26+
#else
27+
return [.none, .scroll, .cover]
28+
#endif
29+
}
30+
2231
var displayName: String {
2332
switch self {
33+
case .none: return String(localized: "reader.page_transition.none")
2434
case .scroll: return String(localized: "reader.page_transition.scroll")
2535
case .cover: return String(localized: "reader.page_transition.cover")
2636
case .pageCurl: return String(localized: "reader.page_transition.page_curl")
@@ -29,6 +39,7 @@ enum PageTransitionStyle: String, CaseIterable, Hashable {
2939

3040
var description: String {
3141
switch self {
42+
case .none: return String(localized: "reader.page_transition.none.description")
3243
case .scroll: return String(localized: "reader.page_transition.scroll.description")
3344
case .cover: return String(localized: "reader.page_transition.cover.description")
3445
case .pageCurl: return String(localized: "reader.page_transition.page_curl.description")

KMReader/Features/Reader/Views/DivinaReaderView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -680,7 +680,7 @@ struct DivinaReaderView: View {
680680
#else
681681
standardScrollPageView(useDualPage: useDualPage, screenSize: screenSize)
682682
#endif
683-
case .scroll:
683+
case .none, .scroll:
684684
standardScrollPageView(useDualPage: useDualPage, screenSize: screenSize)
685685
case .cover:
686686
CoverPageView(

KMReader/Features/Reader/Views/Epub/EpubReaderView.swift

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
@AppStorage("currentAccount") private var current: Current = .init()
2222
@AppStorage("epubPreferences") private var globalPreferences: EpubReaderPreferences = .init()
2323
@AppStorage("epubPageTransitionStyle") private var epubPageTransitionStyle: PageTransitionStyle = .scroll
24+
@AppStorage("epubShowsStatusBarWhileReading") private var epubShowsStatusBarWhileReading: Bool = false
2425
@AppStorage("tapPageTransitionDuration") private var tapPageTransitionDuration: Double = 0.3
2526

2627
@State private var viewModel: EpubReaderViewModel
@@ -84,6 +85,10 @@
8485
return showingControls
8586
}
8687

88+
private var shouldShowStatusBar: Bool {
89+
shouldShowControls || epubShowsStatusBarWhileReading
90+
}
91+
8792
private var handoffBookId: String {
8893
currentBook?.id ?? book.id
8994
}
@@ -154,7 +159,7 @@
154159
var body: some View {
155160
readerBody
156161
#if os(iOS)
157-
.statusBarHidden(!shouldShowControls)
162+
.statusBarHidden(!shouldShowStatusBar)
158163
.iPadIgnoresSafeArea()
159164
#endif
160165
.task(id: book.id) {
@@ -379,6 +384,24 @@
379384
switch activePreferences.flowStyle {
380385
case .paged:
381386
switch epubPageTransitionStyle {
387+
case .none:
388+
WebPubPagedScrollView(
389+
viewModel: viewModel,
390+
animatePageTransitions: false,
391+
preferences: activePreferences,
392+
colorScheme: colorScheme,
393+
showingControls: shouldShowControls,
394+
bookTitle: currentBook?.metadata.title,
395+
onCenterTap: {
396+
toggleControls()
397+
},
398+
onEndReached: {
399+
if !showingEndPage {
400+
viewModel.syncEndProgression()
401+
showingEndPage = true
402+
}
403+
}
404+
)
382405
case .cover:
383406
WebPubPagedCoverView(
384407
viewModel: viewModel,
@@ -399,6 +422,7 @@
399422
case .scroll, .pageCurl:
400423
WebPubPagedScrollView(
401424
viewModel: viewModel,
425+
animatePageTransitions: true,
402426
preferences: activePreferences,
403427
colorScheme: colorScheme,
404428
showingControls: shouldShowControls,
@@ -437,9 +461,28 @@
437461
switch activePreferences.flowStyle {
438462
case .paged:
439463
switch epubPageTransitionStyle {
464+
case .none:
465+
WebPubPagedScrollView(
466+
viewModel: viewModel,
467+
animatePageTransitions: false,
468+
preferences: activePreferences,
469+
colorScheme: colorScheme,
470+
showingControls: shouldShowControls,
471+
bookTitle: currentBook?.metadata.title,
472+
onCenterTap: {
473+
toggleControls()
474+
},
475+
onEndReached: {
476+
if !showingEndPage {
477+
viewModel.syncEndProgression()
478+
showingEndPage = true
479+
}
480+
}
481+
).readerIgnoresSafeArea()
440482
case .scroll:
441483
WebPubPagedScrollView(
442484
viewModel: viewModel,
485+
animatePageTransitions: true,
443486
preferences: activePreferences,
444487
colorScheme: colorScheme,
445488
showingControls: shouldShowControls,

0 commit comments

Comments
 (0)