From 47b6214588aef63d4ea5d5bc55d40da9b9e1ca1c Mon Sep 17 00:00:00 2001 From: ZackYu <59857887+aalberrty@users.noreply.github.com> Date: Mon, 28 Jul 2025 09:15:33 +0800 Subject: [PATCH 01/40] completely refactored Reading View, improved readability and maintainablity --- EhPanda.xcodeproj/project.pbxproj | 76 +- .../xcshareddata/WorkspaceSettings.xcsettings | 5 + .../xcshareddata/swiftpm/Package.resolved | 4 +- EhPanda/App/Info.plist | 28 +- EhPanda/View/Reading/ReadingReducer.swift | 1364 +++++++++++------ EhPanda/View/Reading/ReadingView.swift | 914 +++++------ .../View/Reading/Support/AdvancedList.swift | 189 ++- .../Reading/Support/AutoPlayHandler.swift | 35 - .../Reading/Support/GestureCoordinator.swift | 390 +++++ .../View/Reading/Support/GestureHandler.swift | 131 -- .../View/Reading/Support/ImageStackView.swift | 349 +++++ .../Reading/Support/LiveTextHandler.swift | 191 --- .../Reading/Support/PageCoordinator.swift | 297 ++++ .../View/Reading/Support/PageHandler.swift | 40 - .../Support/ReadingViewExtensions.swift | 445 ++++++ .../Reading/Support/ReadingViewModel.swift | 321 ++++ ShareExtension/Info.plist | 4 +- 17 files changed, 3283 insertions(+), 1500 deletions(-) create mode 100644 EhPanda.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings delete mode 100644 EhPanda/View/Reading/Support/AutoPlayHandler.swift create mode 100644 EhPanda/View/Reading/Support/GestureCoordinator.swift delete mode 100644 EhPanda/View/Reading/Support/GestureHandler.swift create mode 100644 EhPanda/View/Reading/Support/ImageStackView.swift delete mode 100644 EhPanda/View/Reading/Support/LiveTextHandler.swift create mode 100644 EhPanda/View/Reading/Support/PageCoordinator.swift delete mode 100644 EhPanda/View/Reading/Support/PageHandler.swift create mode 100644 EhPanda/View/Reading/Support/ReadingViewExtensions.swift create mode 100644 EhPanda/View/Reading/Support/ReadingViewModel.swift diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index b8d56e7e..46280b8b 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 145E7E3E2E36DE6D00822CB0 /* ReadingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E7E3D2E36DE6D00822CB0 /* ReadingViewModel.swift */; }; + 145E7E3F2E36DE6D00822CB0 /* GestureCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E7E382E36DE6D00822CB0 /* GestureCoordinator.swift */; }; + 145E7E412E36DE6D00822CB0 /* PageCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E7E3A2E36DE6D00822CB0 /* PageCoordinator.swift */; }; + 145E7E422E36DE6D00822CB0 /* ReadingViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E7E3C2E36DE6D00822CB0 /* ReadingViewExtensions.swift */; }; + 145E7E432E36DE6D00822CB0 /* ImageStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E7E392E36DE6D00822CB0 /* ImageStackView.swift */; }; AB0929B6277F043D00F107CA /* AccountSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929B5277F043D00F107CA /* AccountSettingReducer.swift */; }; AB0929BE2780032400F107CA /* EhSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BD2780032400F107CA /* EhSettingReducer.swift */; }; AB0929C027805A8200F107CA /* LoginReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BF27805A8200F107CA /* LoginReducer.swift */; }; @@ -214,14 +219,10 @@ ABC3C78F2593699B00E0C11B /* ViewModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC3C7762593699A00E0C11B /* ViewModifiers.swift */; }; ABC4A0792751B40E00968A4F /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = ABC4A0782751B40E00968A4F /* Kingfisher */; }; ABC681F326898D46007BBD69 /* Model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = ABC681F126898D46007BBD69 /* Model.xcdatamodeld */; }; - ABC732C127B8962000D47DA9 /* LiveTextHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC732C027B8962000D47DA9 /* LiveTextHandler.swift */; }; ABC732C527B9024500D47DA9 /* LiveText.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC732C427B9024500D47DA9 /* LiveText.swift */; }; ABC732C727B90F0900D47DA9 /* LiveTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC732C627B90F0900D47DA9 /* LiveTextView.swift */; }; ABC8355D27B118330091DCDB /* DetailSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8355C27B118330091DCDB /* DetailSearchView.swift */; }; ABC8355F27B118370091DCDB /* DetailSearchReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8355E27B118370091DCDB /* DetailSearchReducer.swift */; }; - ABC8356127B357C50091DCDB /* GestureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8356027B357C50091DCDB /* GestureHandler.swift */; }; - ABC8356327B366760091DCDB /* PageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8356227B366760091DCDB /* PageHandler.swift */; }; - ABC8356527B36E550091DCDB /* AutoPlayHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8356427B36E550091DCDB /* AutoPlayHandler.swift */; }; ABCA93BE26918DE100A98BC6 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCA93BD26918DE100A98BC6 /* Persistence.swift */; }; ABCA93C02691925900A98BC6 /* GalleryMO+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCA93BF2691925900A98BC6 /* GalleryMO+CoreDataClass.swift */; }; ABCA93C22691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCA93C12691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift */; }; @@ -316,6 +317,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 145E7E382E36DE6D00822CB0 /* GestureCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureCoordinator.swift; sourceTree = ""; }; + 145E7E392E36DE6D00822CB0 /* ImageStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageStackView.swift; sourceTree = ""; }; + 145E7E3A2E36DE6D00822CB0 /* PageCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageCoordinator.swift; sourceTree = ""; }; + 145E7E3C2E36DE6D00822CB0 /* ReadingViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingViewExtensions.swift; sourceTree = ""; }; + 145E7E3D2E36DE6D00822CB0 /* ReadingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingViewModel.swift; sourceTree = ""; }; AB0929B5277F043D00F107CA /* AccountSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingReducer.swift; sourceTree = ""; }; AB0929BD2780032400F107CA /* EhSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSettingReducer.swift; sourceTree = ""; }; AB0929BF27805A8200F107CA /* LoginReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginReducer.swift; sourceTree = ""; }; @@ -526,14 +532,10 @@ ABC3C7762593699A00E0C11B /* ViewModifiers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewModifiers.swift; sourceTree = ""; }; ABC4A07A2753084100968A4F /* Model 5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 5.xcdatamodel"; sourceTree = ""; }; ABC681F226898D46007BBD69 /* Model.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model.xcdatamodel; sourceTree = ""; }; - ABC732C027B8962000D47DA9 /* LiveTextHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTextHandler.swift; sourceTree = ""; }; ABC732C427B9024500D47DA9 /* LiveText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveText.swift; sourceTree = ""; }; ABC732C627B90F0900D47DA9 /* LiveTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTextView.swift; sourceTree = ""; }; ABC8355C27B118330091DCDB /* DetailSearchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailSearchView.swift; sourceTree = ""; }; ABC8355E27B118370091DCDB /* DetailSearchReducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailSearchReducer.swift; sourceTree = ""; }; - ABC8356027B357C50091DCDB /* GestureHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureHandler.swift; sourceTree = ""; }; - ABC8356227B366760091DCDB /* PageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHandler.swift; sourceTree = ""; }; - ABC8356427B36E550091DCDB /* AutoPlayHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPlayHandler.swift; sourceTree = ""; }; ABCA93BD26918DE100A98BC6 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; ABCA93BF2691925900A98BC6 /* GalleryMO+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryMO+CoreDataClass.swift"; sourceTree = ""; }; ABCA93C12691929D00A98BC6 /* GalleryDetailMO+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryDetailMO+CoreDataClass.swift"; sourceTree = ""; }; @@ -708,13 +710,14 @@ AB24C561276757A30085C33A /* Support */ = { isa = PBXGroup; children = ( + 145E7E382E36DE6D00822CB0 /* GestureCoordinator.swift */, + 145E7E392E36DE6D00822CB0 /* ImageStackView.swift */, + 145E7E3A2E36DE6D00822CB0 /* PageCoordinator.swift */, + 145E7E3C2E36DE6D00822CB0 /* ReadingViewExtensions.swift */, + 145E7E3D2E36DE6D00822CB0 /* ReadingViewModel.swift */, ABC732C627B90F0900D47DA9 /* LiveTextView.swift */, AB69CB8126B3DAF400699359 /* ControlPanel.swift */, AB69CB7F26B3DABC00699359 /* AdvancedList.swift */, - ABC8356227B366760091DCDB /* PageHandler.swift */, - ABC8356027B357C50091DCDB /* GestureHandler.swift */, - ABC732C027B8962000D47DA9 /* LiveTextHandler.swift */, - ABC8356427B36E550091DCDB /* AutoPlayHandler.swift */, ); path = Support; sourceTree = ""; @@ -1851,7 +1854,6 @@ AB86ABF92782EC0D00E61E6A /* AboutView.swift in Sources */, AB7BF2BA27A96562001865A3 /* Gallery.swift in Sources */, AB0929BE2780032400F107CA /* EhSettingReducer.swift in Sources */, - ABC8356127B357C50091DCDB /* GestureHandler.swift in Sources */, AB0929D42781EDDC00F107CA /* UserDefaultsClient.swift in Sources */, AB0929D82782A83A00F107CA /* AuthorizationClient.swift in Sources */, ABF45AEF25F3313D00ECB568 /* TorrentsView.swift in Sources */, @@ -1896,7 +1898,6 @@ AB7BF2AB27A642FB001865A3 /* BrowsingCountry.swift in Sources */, ABD49D60277C7722003D1A07 /* TabBarView.swift in Sources */, AB26F59027ABF21000AB3468 /* Model5toModel6.xcmappingmodel in Sources */, - ABC8356327B366760091DCDB /* PageHandler.swift in Sources */, AB706FA5278C3DDE0025A48A /* PreviewsView.swift in Sources */, ABF45AE725F3313D00ECB568 /* RatingView.swift in Sources */, AB2CED64268AB6AE003130F7 /* GalleryMO+CoreDataProperties.swift in Sources */, @@ -1906,6 +1907,11 @@ AB706F862789AD490025A48A /* ToplistsReducer.swift in Sources */, AB7BF2CC27A96A3C001865A3 /* GalleryTorrent.swift in Sources */, ABF45AEA25F3313D00ECB568 /* Placeholder.swift in Sources */, + 145E7E3E2E36DE6D00822CB0 /* ReadingViewModel.swift in Sources */, + 145E7E3F2E36DE6D00822CB0 /* GestureCoordinator.swift in Sources */, + 145E7E412E36DE6D00822CB0 /* PageCoordinator.swift in Sources */, + 145E7E422E36DE6D00822CB0 /* ReadingViewExtensions.swift in Sources */, + 145E7E432E36DE6D00822CB0 /* ImageStackView.swift in Sources */, ABD4032826B7967F00001B8C /* CategoryView.swift in Sources */, ABC681F326898D46007BBD69 /* Model.xcdatamodeld in Sources */, ABBB266627977C2A007B6149 /* ArchivesReducer.swift in Sources */, @@ -1936,7 +1942,6 @@ AB706F8A278A4CC50025A48A /* PopularReducer.swift in Sources */, ABD49D64277C7AD5003D1A07 /* TabBarReducer.swift in Sources */, ABF45AF025F3313D00ECB568 /* CommentsView.swift in Sources */, - ABC8356527B36E550091DCDB /* AutoPlayHandler.swift in Sources */, ABBB2671279AFA61007B6149 /* EnvironmentKeys.swift in Sources */, AB7BF2DA27AA78CF001865A3 /* Reducer_Extension.swift in Sources */, ABBD2B602768D7AD0072AED2 /* GalleryRankingCell.swift in Sources */, @@ -1953,7 +1958,6 @@ EA698C092CCDE7090058BC19 /* IdentifiableBox.swift in Sources */, AB0CFBD727C3B2D0004BD372 /* TagDetailView.swift in Sources */, AB38A0CB25CA993D00764D64 /* ColorCodable.swift in Sources */, - ABC732C127B8962000D47DA9 /* LiveTextHandler.swift in Sources */, ABBB2675279B933D007B6149 /* ReadingReducer.swift in Sources */, ABF45AF425F3313D00ECB568 /* WebView.swift in Sources */, AB7B29F626AC741600EE1F14 /* GenericList.swift in Sources */, @@ -2084,10 +2088,10 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 156; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + DEVELOPMENT_TEAM = RYCYM2Y5FL; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2098,9 +2102,9 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; + PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ShareExtension_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -2112,10 +2116,10 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 156; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + DEVELOPMENT_TEAM = RYCYM2Y5FL; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2126,9 +2130,9 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; + PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ShareExtension_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -2263,11 +2267,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + DEVELOPMENT_TEAM = RYCYM2Y5FL; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -2276,9 +2280,9 @@ "@executable_path/Frameworks", ); OTHER_LDFLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; + PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = App_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2292,11 +2296,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 9SKQ7QTZ74; + DEVELOPMENT_TEAM = RYCYM2Y5FL; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -2305,9 +2309,9 @@ "@executable_path/Frameworks", ); OTHER_LDFLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; + PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = App_Dev; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..0c67376e --- /dev/null +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 66fbb20b..65143cfc 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "16a27ab7ae0abfefbbcba73581b3e2380b47a579", - "version" : "2.2.2" + "revision" : "ae208d1a5cf33aee1d43734ea780a09ada6e2a21", + "version" : "2.3.2" } }, { diff --git a/EhPanda/App/Info.plist b/EhPanda/App/Info.plist index 925f724b..97c0ccff 100644 --- a/EhPanda/App/Info.plist +++ b/EhPanda/App/Info.plist @@ -26,25 +26,25 @@ AppIcon_Developer - AppIcon_StandWithUkraine2022 + AppIcon_NotMyPresident CFBundleIconFiles - AppIcon_StandWithUkraine2022 + AppIcon_NotMyPresident - AppIcon_Ukiyoe + AppIcon_StandWithUkraine2022 CFBundleIconFiles - AppIcon_Ukiyoe + AppIcon_StandWithUkraine2022 - AppIcon_NotMyPresident + AppIcon_Ukiyoe CFBundleIconFiles - AppIcon_NotMyPresident + AppIcon_Ukiyoe @@ -76,6 +76,14 @@ AppIcon_Developer_iPad_Pro + AppIcon_NotMyPresident + + CFBundleIconFiles + + AppIcon_NotMyPresident_iPad + AppIcon_NotMyPresident_iPad_Pro + + AppIcon_StandWithUkraine2022 CFBundleIconFiles @@ -92,14 +100,6 @@ AppIcon_Ukiyoe_iPad_Pro - AppIcon_NotMyPresident - - CFBundleIconFiles - - AppIcon_NotMyPresident_iPad - AppIcon_NotMyPresident_iPad_Pro - - CFBundlePrimaryIcon diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index b747f3d9..6371be89 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -3,40 +3,46 @@ // EhPanda // // Created by 荒木辰造 on R 4/01/22. +// Refactored for improved maintainability and modularity by zackie on 2025-07-28. // import SwiftUI import TTProgressHUD import ComposableArchitecture +// MARK: - Reading Reducer @Reducer struct ReadingReducer { + + // MARK: - Route @CasePathable enum Route: Equatable { case hud case share(IdentifiableBox) case readingSetting(EquatableVoid = .init()) } - + + // MARK: - Share Item enum ShareItem: Equatable { + case data(Data) + case image(UIImage) + var associatedValue: Any { switch self { - case .data(let data): - return data - case .image(let image): - return image + case .data(let data): return data + case .image(let image): return image } } - case data(Data) - case image(UIImage) } - + + // MARK: - Image Action enum ImageAction { case copy(Bool) case save(Bool) case share(Bool) } - + + // MARK: - Cancel IDs private enum CancelID: CaseIterable { case fetchImage case fetchDatabaseInfos @@ -47,137 +53,108 @@ struct ReadingReducer { case fetchMPVKeys case fetchMPVImageURL } - + + // MARK: - State @ObservableState struct State: Equatable { + // MARK: - Navigation & UI var route: Route? + var showsPanel = false + var showsSliderPreview = false + var hudConfig: TTProgressHUDConfig = .loading + var forceRefreshID: UUID = .init() + + // MARK: - Gallery Data var gallery: Gallery = .empty var galleryDetail: GalleryDetail? - var readingProgress: Int = .zero - var forceRefreshID: UUID = .init() - var hudConfig: TTProgressHUDConfig = .loading - + + // MARK: - Loading States var webImageLoadSuccessIndices = Set() var imageURLLoadingStates = [Int: LoadingState]() var previewLoadingStates = [Int: LoadingState]() var databaseLoadingState: LoadingState = .loading + + // MARK: - Preview Configuration var previewConfig: PreviewConfig = .normal(rows: 4) - + + // MARK: - URL Storage var previewURLs = [Int: URL]() - var thumbnailURLs = [Int: URL]() var imageURLs = [Int: URL]() var originalImageURLs = [Int: URL]() - + + // MARK: - MPV Support var mpvKey: String? var mpvImageKeys = [Int: String]() var mpvSkipServerIdentifiers = [Int: String]() - - var showsPanel = false - var showsSliderPreview = false - - // Update - func update(stored: inout [Int: T], new: [Int: T], replaceExisting: Bool = true) { - guard !new.isEmpty else { return } - stored = stored.merging(new, uniquingKeysWith: { stored, new in replaceExisting ? new : stored }) - } - mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { - update(stored: &self.previewURLs, new: previewURLs) - } - mutating func updateThumbnailURLs(_ thumbnailURLs: [Int: URL]) { - update(stored: &self.thumbnailURLs, new: thumbnailURLs) - } - mutating func updateImageURLs(_ imageURLs: [Int: URL], _ originalImageURLs: [Int: URL]) { - update(stored: &self.imageURLs, new: imageURLs) - update(stored: &self.originalImageURLs, new: originalImageURLs) - } - - // Image - func containerDataSource(setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> [Int] { - let defaultData = Array(1...gallery.pageCount) - guard isLandscape && setting.enablesDualPageMode - && setting.readingDirection != .vertical - else { return defaultData } - - let data = setting.exceptCover - ? [1] + Array(stride(from: 2, through: gallery.pageCount, by: 2)) - : Array(stride(from: 1, through: gallery.pageCount, by: 2)) - - return data - } - func imageContainerConfigs( - index: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape - ) -> ImageStackConfig { - let direction = setting.readingDirection - let isReversed = direction == .rightToLeft - let isFirstSingle = setting.exceptCover - let isFirstPageAndSingle = index == 1 && isFirstSingle - let isDualPage = isLandscape && setting.enablesDualPageMode && direction != .vertical - let firstIndex = isDualPage && isReversed && !isFirstPageAndSingle ? index + 1 : index - let secondIndex = firstIndex + (isReversed ? -1 : 1) - let isValidFirstRange = firstIndex >= 1 && firstIndex <= gallery.pageCount - let isValidSecondRange = isFirstSingle - ? secondIndex >= 2 && secondIndex <= gallery.pageCount - : secondIndex >= 1 && secondIndex <= gallery.pageCount - return .init( - firstIndex: firstIndex, secondIndex: secondIndex, isFirstAvailable: isValidFirstRange, - isSecondAvailable: !isFirstPageAndSingle && isValidSecondRange && isDualPage - ) - } } - + + // MARK: - Action enum Action: BindableAction { + // MARK: - Binding & Navigation case binding(BindingAction) case setNavigation(Route?) - case toggleShowsPanel - case setOrientationPortrait(Bool) case onPerformDismiss case onAppear(String, Bool) - + case teardown + + // MARK: - Orientation + case setOrientationPortrait(Bool) + + // MARK: - Web Image Actions case onWebImageRetry(Int) case onWebImageSucceeded(Int) case onWebImageFailed(Int) case reloadAllWebImages case retryAllFailedWebImages - + + // MARK: - Image Actions case copyImage(URL) case saveImage(URL) case saveImageDone(Bool) case shareImage(URL) case fetchImage(ImageAction, URL) case fetchImageDone(ImageAction, Result) - + + // MARK: - Data Synchronization case syncReadingProgress(Int) case syncPreviewURLs([Int: URL]) case syncThumbnailURLs([Int: URL]) case syncImageURLs([Int: URL], [Int: URL]) - - case teardown + + // MARK: - Database Operations case fetchDatabaseInfos(String) case fetchDatabaseInfosDone(GalleryState) - + + // MARK: - Preview Operations case fetchPreviewURLs(Int) case fetchPreviewURLsDone(Int, Result<[Int: URL], AppError>) - + + // MARK: - Image URL Operations case fetchImageURLs(Int) case refetchImageURLs(Int) case prefetchImages(Int, Int) - + + // MARK: - Thumbnail Operations case fetchThumbnailURLs(Int) case fetchThumbnailURLsDone(Int, Result<[Int: URL], AppError>) + + // MARK: - Normal Image Operations case fetchNormalImageURLs(Int, [Int: URL]) case fetchNormalImageURLsDone(Int, Result<([Int: URL], [Int: URL]), AppError>) case refetchNormalImageURLs(Int) case refetchNormalImageURLsDone(Int, Result<([Int: URL], HTTPURLResponse?), AppError>) - + + // MARK: - MPV Operations case fetchMPVKeys(Int, URL) case fetchMPVKeysDone(Int, Result<(String, [Int: String]), AppError>) case fetchMPVImageURL(Int, Bool) case fetchMPVImageURLDone(Int, Result<(URL, URL?, String), AppError>) } - + + // MARK: - Dependencies @Dependency(\.appDelegateClient) private var appDelegateClient @Dependency(\.clipboardClient) private var clipboardClient @Dependency(\.databaseClient) private var databaseClient @@ -186,462 +163,871 @@ struct ReadingReducer { @Dependency(\.deviceClient) private var deviceClient @Dependency(\.imageClient) private var imageClient @Dependency(\.urlClient) private var urlClient - + + // MARK: - Body var body: some Reducer { BindingReducer() .onChange(of: \.showsSliderPreview) { _, _ in - Reduce({ _, _ in .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) }) + Reduce({ _, _ in + .run(operation: { _ in + hapticsClient.generateFeedback(.soft) + }) + }) } - + Reduce { state, action in switch action { + // MARK: - Basic Actions case .binding: return .none - + case .setNavigation(let route): - state.route = route - return .none - + return handleSetNavigation(&state, route: route) + case .toggleShowsPanel: - state.showsPanel.toggle() - return .none - - case .setOrientationPortrait(let isPortrait): - var effects = [Effect]() - if isPortrait { - effects.append(.run(operation: { _ in appDelegateClient.setPortraitOrientationMask() })) - effects.append(.run(operation: { _ in await appDelegateClient.setPortraitOrientation() })) - } else { - effects.append(.run(operation: { _ in appDelegateClient.setAllOrientationMask() })) - } - return .merge(effects) - + return handleToggleShowsPanel(&state) + case .onPerformDismiss: - return .run(operation: { _ in hapticsClient.generateFeedback(.light) }) - + return handlePerformDismiss() + case .onAppear(let gid, let enablesLandscape): - var effects: [Effect] = [ - .send(.fetchDatabaseInfos(gid)) - ] - if enablesLandscape { - effects.append(.send(.setOrientationPortrait(false))) - } - return .merge(effects) - + return handleOnAppear(&state, gid: gid, enablesLandscape: enablesLandscape) + + case .teardown: + return handleTeardown(&state) + + // MARK: - Orientation Actions + case .setOrientationPortrait(let isPortrait): + return handleSetOrientationPortrait(isPortrait: isPortrait) + + // MARK: - Web Image Actions case .onWebImageRetry(let index): - state.imageURLLoadingStates[index] = .idle - return .none - + return handleWebImageRetry(&state, index: index) + case .onWebImageSucceeded(let index): - state.imageURLLoadingStates[index] = .idle - state.webImageLoadSuccessIndices.insert(index) - return .none - + return handleWebImageSucceeded(&state, index: index) + case .onWebImageFailed(let index): - state.imageURLLoadingStates[index] = .failed(.webImageFailed) - return .none - + return handleWebImageFailed(&state, index: index) + case .reloadAllWebImages: - state.previewURLs = .init() - state.thumbnailURLs = .init() - state.imageURLs = .init() - state.originalImageURLs = .init() - state.mpvKey = nil - state.mpvImageKeys = .init() - state.mpvSkipServerIdentifiers = .init() - state.forceRefreshID = .init() - return .run { [state] _ in - await databaseClient.removeImageURLs(gid: state.gallery.id) - } - + return handleReloadAllWebImages(&state) + case .retryAllFailedWebImages: - state.imageURLLoadingStates.forEach { (index, loadingState) in - if case .failed = loadingState { - state.imageURLLoadingStates[index] = .idle - } - } - state.previewLoadingStates.forEach { (index, loadingState) in - if case .failed = loadingState { - state.previewLoadingStates[index] = .idle - } - } - return .none - + return handleRetryAllFailedWebImages(&state) + + // MARK: - Image Actions case .copyImage(let imageURL): - return .send(.fetchImage(.copy(imageURL.isGIF), imageURL)) - + return handleCopyImage(imageURL: imageURL) + case .saveImage(let imageURL): - return .send(.fetchImage(.save(imageURL.isGIF), imageURL)) - + return handleSaveImage(imageURL: imageURL) + case .saveImageDone(let isSucceeded): - state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error - return .send(.setNavigation(.hud)) - + return handleSaveImageDone(&state, isSucceeded: isSucceeded) + case .shareImage(let imageURL): - return .send(.fetchImage(.share(imageURL.isGIF), imageURL)) - + return handleShareImage(imageURL: imageURL) + case .fetchImage(let action, let imageURL): - return .run { send in - let result = await imageClient.fetchImage(url: imageURL) - await send(.fetchImageDone(action, result)) - } - .cancellable(id: CancelID.fetchImage) - + return handleFetchImage(action: action, imageURL: imageURL) + case .fetchImageDone(let action, let result): - if case .success(let image) = result { - switch action { - case .copy(let isAnimated): - state.hudConfig = .copiedToClipboardSucceeded - return .merge( - .send(.setNavigation(.hud)), - .run(operation: { _ in clipboardClient.saveImage(image, isAnimated) }) - ) - case .save(let isAnimated): - return .run { send in - let success = await imageClient.saveImageToPhotoLibrary(image, isAnimated) - await send(.saveImageDone(success)) - } - case .share(let isAnimated): - if isAnimated, let data = image.kf.data(format: .GIF) { - return .send(.setNavigation(.share(.init(value: .data(data))))) - } else { - return .send(.setNavigation(.share(.init(value: .image(image))))) - } - } - } else { - state.hudConfig = .error - return .send(.setNavigation(.hud)) - } - + return handleFetchImageDone(&state, action: action, result: result) + + // MARK: - Synchronization Actions case .syncReadingProgress(let progress): - return .run { [state] _ in - await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) - } - + return handleSyncReadingProgress(state: state, progress: progress) + case .syncPreviewURLs(let previewURLs): - return .run { [state] _ in - await databaseClient.updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs) - } - + return handleSyncPreviewURLs(state: state, previewURLs: previewURLs) + case .syncThumbnailURLs(let thumbnailURLs): - return .run { [state] _ in - await databaseClient.updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs) - } - + return handleSyncThumbnailURLs(state: state, thumbnailURLs: thumbnailURLs) + case .syncImageURLs(let imageURLs, let originalImageURLs): - return .run { [state] _ in - await databaseClient.updateImageURLs( - gid: state.gallery.id, - imageURLs: imageURLs, - originalImageURLs: originalImageURLs - ) - } - - case .teardown: - var effects: [Effect] = [ - .merge(CancelID.allCases.map(Effect.cancel(id:))) - ] - if !deviceClient.isPad() { - effects.append(.send(.setOrientationPortrait(true))) - } - return .merge(effects) - + return handleSyncImageURLs( + state: state, + imageURLs: imageURLs, + originalImageURLs: originalImageURLs + ) + + // MARK: - Database Actions case .fetchDatabaseInfos(let gid): - guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } - state.gallery = gallery - state.galleryDetail = databaseClient.fetchGalleryDetail(gid: state.gallery.id) - return .run { [state] send in - guard let dbState = await databaseClient.fetchGalleryState(gid: state.gallery.id) else { return } - await send(.fetchDatabaseInfosDone(dbState)) - } - .cancellable(id: CancelID.fetchDatabaseInfos) - + return handleFetchDatabaseInfos(&state, gid: gid) + case .fetchDatabaseInfosDone(let galleryState): - if let previewConfig = galleryState.previewConfig { - state.previewConfig = previewConfig - } - state.previewURLs = galleryState.previewURLs - state.imageURLs = galleryState.imageURLs - state.thumbnailURLs = galleryState.thumbnailURLs - state.originalImageURLs = galleryState.originalImageURLs - state.readingProgress = galleryState.readingProgress - state.databaseLoadingState = .idle - return .none - + return handleFetchDatabaseInfosDone(&state, galleryState: galleryState) + + // MARK: - Preview Actions case .fetchPreviewURLs(let index): - guard state.previewLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.previewLoadingStates[index] = .loading - let pageNum = state.previewConfig.pageNumber(index: index) - return .run { send in - let response = await GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() - await send(.fetchPreviewURLsDone(index, response)) - } - .cancellable(id: CancelID.fetchPreviewURLs) - + return handleFetchPreviewURLs(&state, index: index) + case .fetchPreviewURLsDone(let index, let result): - switch result { - case .success(let previewURLs): - guard !previewURLs.isEmpty else { - state.previewLoadingStates[index] = .failed(.notFound) - return .none - } - state.previewLoadingStates[index] = .idle - state.updatePreviewURLs(previewURLs) - return .send(.syncPreviewURLs(previewURLs)) - case .failure(let error): - state.previewLoadingStates[index] = .failed(error) - } - return .none - + return handleFetchPreviewURLsDone(&state, index: index, result: result) + + // MARK: - Image URL Actions case .fetchImageURLs(let index): - if state.mpvKey != nil { - return .send(.fetchMPVImageURL(index, false)) - } else { - return .send(.fetchThumbnailURLs(index)) - } - + return handleFetchImageURLs(&state, index: index) + case .refetchImageURLs(let index): - if state.mpvKey != nil { - return .send(.fetchMPVImageURL(index, true)) - } else { - return .send(.refetchNormalImageURLs(index)) - } - + return handleRefetchImageURLs(&state, index: index) + case .prefetchImages(let index, let prefetchLimit): - func getPrefetchImageURLs(range: ClosedRange) -> [URL] { - (range.lowerBound...range.upperBound).compactMap { index in - if let url = state.imageURLs[index] { - return url - } - return nil - } - } - func getFetchImageURLIndices(range: ClosedRange) -> [Int] { - (range.lowerBound...range.upperBound).compactMap { index in - if state.imageURLs[index] == nil, state.imageURLLoadingStates[index] != .loading { - return index - } - return nil - } - } - var prefetchImageURLs = [URL]() - var fetchImageURLIndices = [Int]() - var effects = [Effect]() - let previousUpperBound = max(index - 2, 1) - let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) - if previousUpperBound - previousLowerBound > 0 { - prefetchImageURLs += getPrefetchImageURLs(range: previousLowerBound...previousUpperBound) - fetchImageURLIndices += getFetchImageURLIndices(range: previousLowerBound...previousUpperBound) - } - let nextLowerBound = min(index + 2, state.gallery.pageCount) - let nextUpperBound = min(nextLowerBound + prefetchLimit / 2, state.gallery.pageCount) - if nextUpperBound - nextLowerBound > 0 { - prefetchImageURLs += getPrefetchImageURLs(range: nextLowerBound...nextUpperBound) - fetchImageURLIndices += getFetchImageURLIndices(range: nextLowerBound...nextUpperBound) - } - fetchImageURLIndices.forEach { - effects.append(.send(.fetchImageURLs($0))) - } - effects.append( - .run { [prefetchImageURLs] _ in - imageClient.prefetchImages(prefetchImageURLs) - } - ) - return .merge(effects) - + return handlePrefetchImages(&state, index: index, prefetchLimit: prefetchLimit) + + // MARK: - Thumbnail Actions case .fetchThumbnailURLs(let index): - guard state.imageURLLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.previewConfig.batchRange(index: index).forEach { - state.imageURLLoadingStates[$0] = .loading - } - let pageNum = state.previewConfig.pageNumber(index: index) - return .run { send in - let response = await ThumbnailURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() - await send(.fetchThumbnailURLsDone(index, response)) - } - .cancellable(id: CancelID.fetchThumbnailURLs) - + return handleFetchThumbnailURLs(&state, index: index) + case .fetchThumbnailURLsDone(let index, let result): - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let thumbnailURLs): - guard !thumbnailURLs.isEmpty else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) - } - return .none - } - if let url = thumbnailURLs[index], urlClient.checkIfMPVURL(url) { - return .send(.fetchMPVKeys(index, url)) - } else { - state.updateThumbnailURLs(thumbnailURLs) - return .merge( - .send(.syncThumbnailURLs(thumbnailURLs)), - .send(.fetchNormalImageURLs(index, thumbnailURLs)) - ) - } - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - + return handleFetchThumbnailURLsDone(&state, index: index, result: result) + + // MARK: - Normal Image Actions case .fetchNormalImageURLs(let index, let thumbnailURLs): - return .run { send in - let response = await GalleryNormalImageURLsRequest(thumbnailURLs: thumbnailURLs).response() - await send(.fetchNormalImageURLsDone(index, response)) - } - .cancellable(id: CancelID.fetchNormalImageURLs) - + return handleFetchNormalImageURLs(index: index, thumbnailURLs: thumbnailURLs) + case .fetchNormalImageURLsDone(let index, let result): - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let (imageURLs, originalImageURLs)): - guard !imageURLs.isEmpty else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) - } - return .none - } - batchRange.forEach { - state.imageURLLoadingStates[$0] = .idle - } - state.updateImageURLs(imageURLs, originalImageURLs) - return .send(.syncImageURLs(imageURLs, originalImageURLs)) - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - + return handleFetchNormalImageURLsDone(&state, index: index, result: result) + case .refetchNormalImageURLs(let index): - guard state.imageURLLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL, - let imageURL = state.imageURLs[index] - else { return .none } - state.imageURLLoadingStates[index] = .loading - let pageNum = state.previewConfig.pageNumber(index: index) - return .run { [thumbnailURL = state.thumbnailURLs[index]] send in - let response = await GalleryNormalImageURLRefetchRequest( - index: index, - pageNum: pageNum, - galleryURL: galleryURL, - thumbnailURL: thumbnailURL, - storedImageURL: imageURL - ) - .response() - await send(.refetchNormalImageURLsDone(index, response)) - } - .cancellable(id: CancelID.refetchNormalImageURLs) - + return handleRefetchNormalImageURLs(&state, index: index) + case .refetchNormalImageURLsDone(let index, let result): - switch result { - case .success(let (imageURLs, response)): - var effects = [Effect]() - if let response = response { - effects.append(.run(operation: { _ in cookieClient.setSkipServer(response: response) })) - } - guard !imageURLs.isEmpty else { - state.imageURLLoadingStates[index] = .failed(.notFound) - return effects.isEmpty ? .none : .merge(effects) - } - state.imageURLLoadingStates[index] = .idle - state.updateImageURLs(imageURLs, [:]) - effects.append(.send(.syncImageURLs(imageURLs, [:]))) - return .merge(effects) - case .failure(let error): - state.imageURLLoadingStates[index] = .failed(error) - } - return .none - + return handleRefetchNormalImageURLsDone(&state, index: index, result: result) + + // MARK: - MPV Actions case .fetchMPVKeys(let index, let mpvURL): - return .run { send in - let response = await MPVKeysRequest(mpvURL: mpvURL).response() - await send(.fetchMPVKeysDone(index, response)) - } - .cancellable(id: CancelID.fetchMPVKeys) - + return handleFetchMPVKeys(index: index, mpvURL: mpvURL) + case .fetchMPVKeysDone(let index, let result): - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let (mpvKey, mpvImageKeys)): - let pageCount = state.gallery.pageCount - guard mpvImageKeys.count == pageCount else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) - } - return .none - } - batchRange.forEach { - state.imageURLLoadingStates[$0] = .idle - } - state.mpvKey = mpvKey - state.mpvImageKeys = mpvImageKeys - return .merge( - Array(1...min(3, max(1, pageCount))).map { - .send(.fetchMPVImageURL($0, false)) - } - ) - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } + return handleFetchMPVKeysDone(&state, index: index, result: result) + + case .fetchMPVImageURL(let index, let isRefresh): + return handleFetchMPVImageURL(&state, index: index, isRefresh: isRefresh) + + case .fetchMPVImageURLDone(let index, let result): + return handleFetchMPVImageURLDone(&state, index: index, result: result) + } + } + .haptics(unwrapping: \.route, case: \.readingSetting, hapticsClient: hapticsClient) + .haptics(unwrapping: \.route, case: \.share, hapticsClient: hapticsClient) + } + + // MARK: - Handler Methods + + /// Basic Action Handlers + func handleSetNavigation(_ state: inout State, route: Route?) -> Effect { + state.route = route + return .none + } + + func handleToggleShowsPanel(_ state: inout State) -> Effect { + state.showsPanel.toggle() + return .none + } + + func handlePerformDismiss() -> Effect { + return .run(operation: { _ in + hapticsClient.generateFeedback(.light) + }) + } + + func handleOnAppear(_ state: inout State, gid: String, enablesLandscape: Bool) -> Effect { + var effects: [Effect] = [ + .send(.fetchDatabaseInfos(gid)) + ] + if enablesLandscape { + effects.append(.send(.setOrientationPortrait(false))) + } + return .merge(effects) + } + + func handleTeardown(_ state: inout State) -> Effect { + var effects: [Effect] = [ + .merge(CancelID.allCases.map(Effect.cancel(id:))) + ] + if !deviceClient.isPad() { + effects.append(.send(.setOrientationPortrait(true))) + } + return .merge(effects) + } + + /// Orientation Handlers + func handleSetOrientationPortrait(isPortrait: Bool) -> Effect { + var effects = [Effect]() + if isPortrait { + effects.append(.run(operation: { _ in + appDelegateClient.setPortraitOrientationMask() + })) + effects.append(.run(operation: { _ in + await appDelegateClient.setPortraitOrientation() + })) + } else { + effects.append(.run(operation: { _ in + appDelegateClient.setAllOrientationMask() + })) + } + return .merge(effects) + } + + /// Web Image Handlers + func handleWebImageRetry(_ state: inout State, index: Int) -> Effect { + state.imageURLLoadingStates[index] = .idle + return .none + } + + func handleWebImageSucceeded(_ state: inout State, index: Int) -> Effect { + state.imageURLLoadingStates[index] = .idle + state.webImageLoadSuccessIndices.insert(index) + return .none + } + + func handleWebImageFailed(_ state: inout State, index: Int) -> Effect { + state.imageURLLoadingStates[index] = .failed(.webImageFailed) + return .none + } + + func handleReloadAllWebImages(_ state: inout State) -> Effect { + state.previewURLs = .init() + state.thumbnailURLs = .init() + state.imageURLs = .init() + state.originalImageURLs = .init() + state.mpvKey = nil + state.mpvImageKeys = .init() + state.mpvSkipServerIdentifiers = .init() + state.forceRefreshID = .init() + + return .run { [galleryId = state.gallery.id] _ in + await databaseClient.removeImageURLs(gid: galleryId) + } + } + + func handleRetryAllFailedWebImages(_ state: inout State) -> Effect { + state.imageURLLoadingStates.forEach { (index, loadingState) in + if case .failed = loadingState { + state.imageURLLoadingStates[index] = .idle + } + } + state.previewLoadingStates.forEach { (index, loadingState) in + if case .failed = loadingState { + state.previewLoadingStates[index] = .idle + } + } + return .none + } + + /// Image Action Handlers + func handleCopyImage(imageURL: URL) -> Effect { + return .send(.fetchImage(.copy(imageURL.isGIF), imageURL)) + } + + func handleSaveImage(imageURL: URL) -> Effect { + return .send(.fetchImage(.save(imageURL.isGIF), imageURL)) + } + + func handleSaveImageDone(_ state: inout State, isSucceeded: Bool) -> Effect { + state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error + return .send(.setNavigation(.hud)) + } + + func handleShareImage(imageURL: URL) -> Effect { + return .send(.fetchImage(.share(imageURL.isGIF), imageURL)) + } + + func handleFetchImage(action: ImageAction, imageURL: URL) -> Effect { + return .run { send in + let result = await imageClient.fetchImage(url: imageURL) + await send(.fetchImageDone(action, result)) + } + .cancellable(id: CancelID.fetchImage) + } + + func handleFetchImageDone( + _ state: inout State, + action: ImageAction, + result: Result + ) -> Effect { + switch result { + case .success(let image): + return handleSuccessfulImageFetch(state: &state, action: action, image: image) + case .failure: + state.hudConfig = .error + return .send(.setNavigation(.hud)) + } + } + + private func handleSuccessfulImageFetch( + state: inout State, + action: ImageAction, + image: UIImage + ) -> Effect { + switch action { + case .copy(let isAnimated): + state.hudConfig = .copiedToClipboardSucceeded + return .merge( + .send(.setNavigation(.hud)), + .run(operation: { _ in + clipboardClient.saveImage(image, isAnimated) + }) + ) + case .save(let isAnimated): + return .run { send in + let success = await imageClient.saveImageToPhotoLibrary(image, isAnimated) + await send(.saveImageDone(success)) + } + case .share(let isAnimated): + if isAnimated, let data = image.kf.data(format: .GIF) { + return .send(.setNavigation(.share(.init(value: .data(data))))) + } else { + return .send(.setNavigation(.share(.init(value: .image(image))))) + } + } + } + + /// Synchronization Handlers + func handleSyncReadingProgress(state: State, progress: Int) -> Effect { + return .run { _ in + await databaseClient.updateReadingProgress( + gid: state.gallery.id, + progress: progress + ) + } + } + + func handleSyncPreviewURLs(state: State, previewURLs: [Int: URL]) -> Effect { + return .run { _ in + await databaseClient.updatePreviewURLs( + gid: state.gallery.id, + previewURLs: previewURLs + ) + } + } + + func handleSyncThumbnailURLs(state: State, thumbnailURLs: [Int: URL]) -> Effect { + return .run { _ in + await databaseClient.updateThumbnailURLs( + gid: state.gallery.id, + thumbnailURLs: thumbnailURLs + ) + } + } + + func handleSyncImageURLs( + state: State, + imageURLs: [Int: URL], + originalImageURLs: [Int: URL] + ) -> Effect { + return .run { _ in + await databaseClient.updateImageURLs( + gid: state.gallery.id, + imageURLs: imageURLs, + originalImageURLs: originalImageURLs + ) + } + } + + /// Database Handlers + func handleFetchDatabaseInfos(_ state: inout State, gid: String) -> Effect { + guard let gallery = databaseClient.fetchGallery(gid: gid) else { + return .none + } + + state.gallery = gallery + state.galleryDetail = databaseClient.fetchGalleryDetail(gid: state.gallery.id) + + return .run { [galleryId = state.gallery.id] send in + guard let dbState = await databaseClient.fetchGalleryState(gid: galleryId) else { + return + } + await send(.fetchDatabaseInfosDone(dbState)) + } + .cancellable(id: CancelID.fetchDatabaseInfos) + } + + func handleFetchDatabaseInfosDone( + _ state: inout State, + galleryState: GalleryState + ) -> Effect { + if let previewConfig = galleryState.previewConfig { + state.previewConfig = previewConfig + } + state.previewURLs = galleryState.previewURLs + state.imageURLs = galleryState.imageURLs + state.thumbnailURLs = galleryState.thumbnailURLs + state.originalImageURLs = galleryState.originalImageURLs + state.readingProgress = galleryState.readingProgress + state.databaseLoadingState = .idle + return .none + } + + /// Preview Handlers + func handleFetchPreviewURLs(_ state: inout State, index: Int) -> Effect { + guard state.previewLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL + else { + return .none + } + + state.previewLoadingStates[index] = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + + return .run { send in + let response = await GalleryPreviewURLsRequest( + galleryURL: galleryURL, + pageNum: pageNum + ).response() + await send(.fetchPreviewURLsDone(index, response)) + } + .cancellable(id: CancelID.fetchPreviewURLs) + } + + func handleFetchPreviewURLsDone( + _ state: inout State, + index: Int, + result: Result<[Int: URL], AppError> + ) -> Effect { + switch result { + case .success(let previewURLs): + guard !previewURLs.isEmpty else { + state.previewLoadingStates[index] = .failed(.notFound) + return .none + } + state.previewLoadingStates[index] = .idle + state.updatePreviewURLs(previewURLs) + return .send(.syncPreviewURLs(previewURLs)) + case .failure(let error): + state.previewLoadingStates[index] = .failed(error) + return .none + } + } + + /// Image URL Handlers + func handleFetchImageURLs(_ state: inout State, index: Int) -> Effect { + if state.mpvKey != nil { + return .send(.fetchMPVImageURL(index, false)) + } else { + return .send(.fetchThumbnailURLs(index)) + } + } + + func handleRefetchImageURLs(_ state: inout State, index: Int) -> Effect { + if state.mpvKey != nil { + return .send(.fetchMPVImageURL(index, true)) + } else { + return .send(.refetchNormalImageURLs(index)) + } + } + + func handlePrefetchImages( + _ state: inout State, + index: Int, + prefetchLimit: Int + ) -> Effect { + let prefetchHelper = PrefetchHelper(state: state, imageClient: imageClient) + return prefetchHelper.createPrefetchEffects( + currentIndex: index, + prefetchLimit: prefetchLimit + ) + } + + /// Thumbnail Handlers + func handleFetchThumbnailURLs(_ state: inout State, index: Int) -> Effect { + guard state.imageURLLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL + else { + return .none + } + + state.previewConfig.batchRange(index: index).forEach { + state.imageURLLoadingStates[$0] = .loading + } + + let pageNum = state.previewConfig.pageNumber(index: index) + + return .run { send in + let response = await ThumbnailURLsRequest( + galleryURL: galleryURL, + pageNum: pageNum + ).response() + await send(.fetchThumbnailURLsDone(index, response)) + } + .cancellable(id: CancelID.fetchThumbnailURLs) + } + + func handleFetchThumbnailURLsDone( + _ state: inout State, + index: Int, + result: Result<[Int: URL], AppError> + ) -> Effect { + let batchRange = state.previewConfig.batchRange(index: index) + + switch result { + case .success(let thumbnailURLs): + guard !thumbnailURLs.isEmpty else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) } return .none - - case .fetchMPVImageURL(let index, let isRefresh): - guard let gidInteger = Int(state.gallery.id), let mpvKey = state.mpvKey, - let mpvImageKey = state.mpvImageKeys[index], - state.imageURLLoadingStates[index] != .loading - else { return .none } - state.imageURLLoadingStates[index] = .loading - let skipServerIdentifier = isRefresh ? state.mpvSkipServerIdentifiers[index] : nil - return .run { send in - let response = await GalleryMPVImageURLRequest( - gid: gidInteger, - index: index, - mpvKey: mpvKey, - mpvImageKey: mpvImageKey, - skipServerIdentifier: skipServerIdentifier - ) - .response() - await send(.fetchMPVImageURLDone(index, response)) + } + + if let url = thumbnailURLs[index], urlClient.checkIfMPVURL(url) { + return .send(.fetchMPVKeys(index, url)) + } else { + state.updateThumbnailURLs(thumbnailURLs) + return .merge( + .send(.syncThumbnailURLs(thumbnailURLs)), + .send(.fetchNormalImageURLs(index, thumbnailURLs)) + ) + } + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + return .none + } + } + + /// Normal Image Handlers + func handleFetchNormalImageURLs( + index: Int, + thumbnailURLs: [Int: URL] + ) -> Effect { + return .run { send in + let response = await GalleryNormalImageURLsRequest( + thumbnailURLs: thumbnailURLs + ).response() + await send(.fetchNormalImageURLsDone(index, response)) + } + .cancellable(id: CancelID.fetchNormalImageURLs) + } + + func handleFetchNormalImageURLsDone( + _ state: inout State, + index: Int, + result: Result<([Int: URL], [Int: URL]), AppError> + ) -> Effect { + let batchRange = state.previewConfig.batchRange(index: index) + + switch result { + case .success(let (imageURLs, originalImageURLs)): + guard !imageURLs.isEmpty else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) } - .cancellable(id: CancelID.fetchMPVImageURL) - - case .fetchMPVImageURLDone(let index, let result): - switch result { - case .success(let (imageURL, originalImageURL, skipServerIdentifier)): - let imageURLs: [Int: URL] = [index: imageURL] - var originalImageURLs = [Int: URL]() - if let originalImageURL = originalImageURL { - originalImageURLs[index] = originalImageURL - } - state.imageURLLoadingStates[index] = .idle - state.mpvSkipServerIdentifiers[index] = skipServerIdentifier - state.updateImageURLs(imageURLs, originalImageURLs) - return .send(.syncImageURLs(imageURLs, originalImageURLs)) - case .failure(let error): - state.imageURLLoadingStates[index] = .failed(error) + return .none + } + + batchRange.forEach { + state.imageURLLoadingStates[$0] = .idle + } + state.updateImageURLs(imageURLs, originalImageURLs) + return .send(.syncImageURLs(imageURLs, originalImageURLs)) + + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + return .none + } + } + + func handleRefetchNormalImageURLs(_ state: inout State, index: Int) -> Effect { + guard state.imageURLLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL, + let imageURL = state.imageURLs[index] + else { + return .none + } + + state.imageURLLoadingStates[index] = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + + return .run { [thumbnailURL = state.thumbnailURLs[index]] send in + let response = await GalleryNormalImageURLRefetchRequest( + index: index, + pageNum: pageNum, + galleryURL: galleryURL, + thumbnailURL: thumbnailURL, + storedImageURL: imageURL + ).response() + await send(.refetchNormalImageURLsDone(index, response)) + } + .cancellable(id: CancelID.refetchNormalImageURLs) + } + + func handleRefetchNormalImageURLsDone( + _ state: inout State, + index: Int, + result: Result<([Int: URL], HTTPURLResponse?), AppError> + ) -> Effect { + switch result { + case .success(let (imageURLs, response)): + var effects = [Effect]() + + if let response = response { + effects.append(.run(operation: { _ in + cookieClient.setSkipServer(response: response) + })) + } + + guard !imageURLs.isEmpty else { + state.imageURLLoadingStates[index] = .failed(.notFound) + return effects.isEmpty ? .none : .merge(effects) + } + + state.imageURLLoadingStates[index] = .idle + state.updateImageURLs(imageURLs, [:]) + effects.append(.send(.syncImageURLs(imageURLs, [:]))) + return .merge(effects) + + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) + return .none + } + } + + /// MPV Handlers + func handleFetchMPVKeys(index: Int, mpvURL: URL) -> Effect { + return .run { send in + let response = await MPVKeysRequest(mpvURL: mpvURL).response() + await send(.fetchMPVKeysDone(index, response)) + } + .cancellable(id: CancelID.fetchMPVKeys) + } + + func handleFetchMPVKeysDone( + _ state: inout State, + index: Int, + result: Result<(String, [Int: String]), AppError> + ) -> Effect { + let batchRange = state.previewConfig.batchRange(index: index) + + switch result { + case .success(let (mpvKey, mpvImageKeys)): + let pageCount = state.gallery.pageCount + guard mpvImageKeys.count == pageCount else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) } return .none } + + batchRange.forEach { + state.imageURLLoadingStates[$0] = .idle + } + state.mpvKey = mpvKey + state.mpvImageKeys = mpvImageKeys + + return .merge( + Array(1...min(3, max(1, pageCount))).map { + .send(.fetchMPVImageURL($0, false)) + } + ) + + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + return .none } - .haptics( - unwrapping: \.route, - case: \.readingSetting, - hapticsClient: hapticsClient - ) - .haptics( - unwrapping: \.route, - case: \.share, - hapticsClient: hapticsClient + } + + func handleFetchMPVImageURL( + _ state: inout State, + index: Int, + isRefresh: Bool + ) -> Effect { + guard let gidInteger = Int(state.gallery.id), + let mpvKey = state.mpvKey, + let mpvImageKey = state.mpvImageKeys[index], + state.imageURLLoadingStates[index] != .loading + else { + return .none + } + + state.imageURLLoadingStates[index] = .loading + let skipServerIdentifier = isRefresh ? state.mpvSkipServerIdentifiers[index] : nil + + return .run { send in + let response = await GalleryMPVImageURLRequest( + gid: gidInteger, + index: index, + mpvKey: mpvKey, + mpvImageKey: mpvImageKey, + skipServerIdentifier: skipServerIdentifier + ).response() + await send(.fetchMPVImageURLDone(index, response)) + } + .cancellable(id: CancelID.fetchMPVImageURL) + } + + func handleFetchMPVImageURLDone( + _ state: inout State, + index: Int, + result: Result<(URL, URL?, String), AppError> + ) -> Effect { + switch result { + case .success(let (imageURL, originalImageURL, skipServerIdentifier)): + let imageURLs: [Int: URL] = [index: imageURL] + var originalImageURLs = [Int: URL]() + if let originalImageURL = originalImageURL { + originalImageURLs[index] = originalImageURL + } + + state.imageURLLoadingStates[index] = .idle + state.mpvSkipServerIdentifiers[index] = skipServerIdentifier + state.updateImageURLs(imageURLs, originalImageURLs) + return .send(.syncImageURLs(imageURLs, originalImageURLs)) + + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) + return .none + } + } +} + +// MARK: - State Extensions +extension ReadingReducer.State { + /// Updates preview URLs + mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { + guard !previewURLs.isEmpty else { return } + self.previewURLs = self.previewURLs.merging(previewURLs) { _, new in new } + } + + /// Updates thumbnail URLs + mutating func updateThumbnailURLs(_ thumbnailURLs: [Int: URL]) { + guard !thumbnailURLs.isEmpty else { return } + self.thumbnailURLs = self.thumbnailURLs.merging(thumbnailURLs) { _, new in new } + } + + /// Updates image URLs and original image URLs + mutating func updateImageURLs(_ imageURLs: [Int: URL], _ originalImageURLs: [Int: URL]) { + if !imageURLs.isEmpty { + self.imageURLs = self.imageURLs.merging(imageURLs) { _, new in new } + } + if !originalImageURLs.isEmpty { + self.originalImageURLs = self.originalImageURLs.merging(originalImageURLs) { _, new in new } + } + } + + /// Gets container data source for the current configuration + func containerDataSource(setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> [Int] { + let defaultData = Array(1...gallery.pageCount) + + guard isLandscape && + setting.enablesDualPageMode && + setting.readingDirection != .vertical + else { + return defaultData + } + + let data = setting.exceptCover + ? [1] + Array(stride(from: 2, through: gallery.pageCount, by: 2)) + : Array(stride(from: 1, through: gallery.pageCount, by: 2)) + + return data + } + + /// Gets image container configurations for dual page mode + func imageContainerConfigs( + index: Int, + setting: Setting, + isLandscape: Bool = DeviceUtil.isLandscape + ) -> ImageStackConfig { + let direction = setting.readingDirection + let isReversed = direction == .rightToLeft + let isFirstSingle = setting.exceptCover + let isFirstPageAndSingle = index == 1 && isFirstSingle + let isDualPage = isLandscape && setting.enablesDualPageMode && direction != .vertical + + let firstIndex = isDualPage && isReversed && !isFirstPageAndSingle ? index + 1 : index + let secondIndex = firstIndex + (isReversed ? -1 : 1) + + let isValidFirstRange = firstIndex >= 1 && firstIndex <= gallery.pageCount + let isValidSecondRange = isFirstSingle + ? secondIndex >= 2 && secondIndex <= gallery.pageCount + : secondIndex >= 1 && secondIndex <= gallery.pageCount + + let dualPageConfig = DualPageConfiguration( + firstIndex: firstIndex, + secondIndex: secondIndex, + isFirstAvailable: isValidFirstRange, + isSecondAvailable: !isFirstPageAndSingle && isValidSecondRange && isDualPage, + isDualPage: isDualPage ) + + return ImageStackConfig(from: dualPageConfig) } } + +// MARK: - Helper Classes + +/// Helper class for managing prefetch operations +private struct PrefetchHelper { + let state: ReadingReducer.State + let imageClient: ImageClient + + func createPrefetchEffects(currentIndex: Int, prefetchLimit: Int) -> Effect { + let (prefetchURLs, fetchIndices) = calculatePrefetchData( + currentIndex: currentIndex, + prefetchLimit: prefetchLimit + ) + + var effects = fetchIndices.map { index in + Effect.send(.fetchImageURLs(index)) + } + + effects.append( + .run { _ in + imageClient.prefetchImages(prefetchURLs) + } + ) + + return .merge(effects) + } + + private func calculatePrefetchData( + currentIndex: Int, + prefetchLimit: Int + ) -> (urls: [URL], indices: [Int]) { + var prefetchURLs = [URL]() + var fetchIndices = [Int]() + + // Previous pages + let previousUpperBound = max(currentIndex - 2, 1) + let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) + if previousUpperBound - previousLowerBound > 0 { + let previousRange = previousLowerBound...previousUpperBound + prefetchURLs += getURLsForRange(previousRange) + fetchIndices += getIndicesNeedingFetch(previousRange) + } + + // Next pages + let nextLowerBound = min(currentIndex + 2, state.gallery.pageCount) + let nextUpperBound = min(nextLowerBound + prefetchLimit / 2, state.gallery.pageCount) + if nextUpperBound - nextLowerBound > 0 { + let nextRange = nextLowerBound...nextUpperBound + prefetchURLs += getURLsForRange(nextRange) + fetchIndices += getIndicesNeedingFetch(nextRange) + } + + return (prefetchURLs, fetchIndices) + } + + private func getURLsForRange(_ range: ClosedRange) -> [URL] { + return range.compactMap { index in + state.imageURLs[index] + } + } + + private func getIndicesNeedingFetch(_ range: ClosedRange) -> [Int] { + return range.compactMap { index in + if state.imageURLs[index] == nil && + state.imageURLLoadingStates[index] != .loading { + return index + } + return nil + } + } +} \ No newline at end of file diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index bfc301fb..0b2d6da3 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -3,6 +3,7 @@ // EhPanda // // Created by 荒木辰造 on R 4/01/22. +// Refactored for improved maintainability by zackie on 2025-07-28. // import SwiftUI @@ -10,624 +11,467 @@ import Kingfisher import SwiftUIPager import ComposableArchitecture +// MARK: - Main Reading View struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme - @Bindable var store: StoreOf + + // MARK: - Configuration private let gid: String @Binding private var setting: Setting private let blurRadius: Double - @StateObject private var liveTextHandler = LiveTextHandler() - @StateObject private var autoPlayHandler = AutoPlayHandler() - @StateObject private var gestureHandler = GestureHandler() - @StateObject private var pageHandler = PageHandler() + // MARK: - View Models + @StateObject private var viewModel: ReadingViewModel + @StateObject private var gestureCoordinator: GestureCoordinator + @StateObject private var pageCoordinator: PageCoordinator @StateObject private var page: Page = .first() + // MARK: - Initialization init( store: StoreOf, - gid: String, setting: Binding, blurRadius: Double + gid: String, + setting: Binding, + blurRadius: Double ) { self.store = store self.gid = gid _setting = setting self.blurRadius = blurRadius + + // Initialize view models with dependencies + _viewModel = StateObject(wrappedValue: ReadingViewModel()) + _gestureCoordinator = StateObject(wrappedValue: GestureCoordinator()) + _pageCoordinator = StateObject(wrappedValue: PageCoordinator()) } - - private var backgroundColor: Color { - colorScheme == .light ? Color(.systemGray4) : Color(.systemGray6) - } - + + // MARK: - Body var body: some View { - changeTriggers(content: { content }) - .sheet(item: $store.route.sending(\.setNavigation).readingSetting) { _ in - NavigationView { - ReadingSettingView( - readingDirection: $setting.readingDirection, - prefetchLimit: $setting.prefetchLimit, - enablesLandscape: $setting.enablesLandscape, - contentDividerHeight: $setting.contentDividerHeight, - maximumScaleFactor: $setting.maximumScaleFactor, - doubleTapScaleFactor: $setting.doubleTapScaleFactor - ) - .toolbar { - CustomToolbarItem(placement: .cancellationAction) { - if !DeviceUtil.isPad && DeviceUtil.isLandscape { - Button { - store.send(.setNavigation(nil)) - } label: { - Image(systemSymbol: .chevronDown) - } - } - } - } - } - .accentColor(setting.accentColor) - .tint(setting.accentColor) - .autoBlur(radius: blurRadius) - .navigationViewStyle(.stack) - } - .sheet(item: $store.route.sending(\.setNavigation).share) { shareItemBox in - ActivityView(activityItems: [shareItemBox.wrappedValue.associatedValue]) - .accentColor(setting.accentColor) - .autoBlur(radius: blurRadius) - } - .progressHUD( - config: store.hudConfig, - unwrapping: $store.route, - case: \.hud - ) - - .animation(.linear(duration: 0.1), value: gestureHandler.offset) - .animation(.default, value: liveTextHandler.enablesLiveText) - .animation(.default, value: liveTextHandler.liveTextGroups) - .animation(.default, value: gestureHandler.scale) - .animation(.default, value: store.showsPanel) - .statusBar(hidden: !store.showsPanel) - .onDisappear { - liveTextHandler.cancelRequests() - setAutoPlayPolocy(.off) - } - .onAppear { store.send(.onAppear(gid, setting.enablesLandscape)) } - } - - var content: some View { ZStack { backgroundColor.ignoresSafeArea() - ZStack { - if setting.readingDirection == .vertical { - AdvancedList( - page: page, - data: store.state.containerDataSource(setting: setting), - id: \.self, - spacing: setting.contentDividerHeight, - gesture: SimultaneousGesture(magnificationGesture, tapGesture), - content: imageStack - ) - .scrollDisabled(gestureHandler.scale != 1) - } else { - Pager( - page: page, - data: store.state.containerDataSource(setting: setting), - id: \.self, - content: imageStack - ) - .horizontal(setting.readingDirection == .rightToLeft ? .endToStart : .startToEnd) - .swipeInteractionArea(.allAvailable) - .allowsDragging(gestureHandler.scale == 1) - } - } - .scaleEffect(gestureHandler.scale, anchor: gestureHandler.scaleAnchor) - .offset(gestureHandler.offset) - .highPriorityGesture( - dragGesture.simultaneously(with: tapGesture), - isEnabled: gestureHandler.scale > 1 + ReadingContentView( + store: store, + setting: $setting, + viewModel: viewModel, + gestureCoordinator: gestureCoordinator, + pageCoordinator: pageCoordinator, + page: page ) - .gesture(tapGesture, isEnabled: gestureHandler.scale == 1) - .gesture(magnificationGesture) - .ignoresSafeArea() - .id(store.databaseLoadingState) - .id(store.forceRefreshID) - - ControlPanel( - showsPanel: $store.showsPanel, - showsSliderPreview: $store.showsSliderPreview, - sliderValue: $pageHandler.sliderValue, setting: $setting, - enablesLiveText: $liveTextHandler.enablesLiveText, - autoPlayPolicy: .init(get: { autoPlayHandler.policy }, set: { setAutoPlayPolocy($0) }), - range: 1...Float(store.gallery.pageCount), - previewURLs: store.previewURLs, - dismissGesture: controlPanelDismissGesture, - dismissAction: { store.send(.onPerformDismiss) }, - navigateSettingAction: { store.send(.setNavigation(.readingSetting())) }, - reloadAllImagesAction: { store.send(.reloadAllWebImages) }, - retryAllFailedImagesAction: { store.send(.retryAllFailedWebImages) }, - fetchPreviewURLsAction: { store.send(.fetchPreviewURLs($0)) } + + ReadingControlsOverlay( + store: store, + setting: $setting, + viewModel: viewModel, + pageCoordinator: pageCoordinator, + gestureCoordinator: gestureCoordinator, + page: page ) } - } - - @ViewBuilder - private func changeTriggers(@ViewBuilder content: () -> Content) -> some View { - content() - // Page - .onChange(of: page.index) { _, newValue in - Logger.info("page.index changed", context: ["pageIndex": newValue]) - let newValue = pageHandler.mapFromPager( - index: newValue, pageCount: store.gallery.pageCount, setting: setting - ) - pageHandler.sliderValue = .init(newValue) - if store.databaseLoadingState == .idle { - store.send(.syncReadingProgress(.init(newValue))) - } - } - .onChange(of: pageHandler.sliderValue) { _, newValue in - Logger.info("pageHandler.sliderValue changed", context: ["sliderValue": newValue]) - if !store.showsSliderPreview { - setPageIndex(sliderValue: newValue) - } - } - .onChange(of: store.showsSliderPreview) { _, newValue in - Logger.info("store.showsSliderPreview changed", context: ["isShown": newValue]) - if !newValue { setPageIndex(sliderValue: pageHandler.sliderValue) } - setAutoPlayPolocy(.off) - } - .onChange(of: store.readingProgress) { _, newValue in - Logger.info("store.readingProgress changed", context: ["readingProgress": newValue]) - pageHandler.sliderValue = .init(newValue) - } - - // AutoPlay - .onChange(of: store.route) { _, newValue in - Logger.info("store.route changed", context: ["route": newValue]) - if ![.hud, .none].contains(newValue) { - setAutoPlayPolocy(.off) - } - } - - // LiveText - .onChange(of: liveTextHandler.enablesLiveText) { _, newValue in - Logger.info("liveTextHandler.enablesLiveText changed", context: ["isEnabled": newValue]) - if newValue { store.webImageLoadSuccessIndices.forEach(analyzeImageForLiveText) } - } - .onChange(of: store.webImageLoadSuccessIndices) { _, newValue in - Logger.info("store.webImageLoadSuccessIndices changed", context: [ - "count": store.webImageLoadSuccessIndices.count - ]) - if liveTextHandler.enablesLiveText { - newValue.forEach(analyzeImageForLiveText) - } - } - - // Orientation - .onChange(of: setting.enablesLandscape) { _, newValue in - Logger.info("setting.enablesLandscape changed", context: ["newValue": newValue]) - store.send(.setOrientationPortrait(!newValue)) - } - } - - @ViewBuilder private func imageStack(index: Int) -> some View { - let imageStackConfig = store.state.imageContainerConfigs(index: index, setting: setting) - let isDualPage = setting.enablesDualPageMode && setting.readingDirection != .vertical && DeviceUtil.isLandscape - HorizontalImageStack( - index: index, - isDualPage: isDualPage, - isDatabaseLoading: store.databaseLoadingState != .idle, - backgroundColor: backgroundColor, - config: imageStackConfig, - imageURLs: store.imageURLs, - originalImageURLs: store.originalImageURLs, - loadingStates: store.imageURLLoadingStates, - enablesLiveText: liveTextHandler.enablesLiveText, - liveTextGroups: liveTextHandler.liveTextGroups, - focusedLiveTextGroup: liveTextHandler.focusedLiveTextGroup, - liveTextTapAction: liveTextHandler.setFocusedLiveTextGroup, - fetchAction: { store.send(.fetchImageURLs($0)) }, - refetchAction: { store.send(.refetchImageURLs($0)) }, - prefetchAction: { store.send(.prefetchImages($0, setting.prefetchLimit)) }, - loadRetryAction: { store.send(.onWebImageRetry($0)) }, - loadSucceededAction: { store.send(.onWebImageSucceeded($0)) }, - loadFailedAction: { store.send(.onWebImageFailed($0)) }, - copyImageAction: { store.send(.copyImage($0)) }, - saveImageAction: { store.send(.saveImage($0)) }, - shareImageAction: { store.send(.shareImage($0)) } - ) - } -} - -// MARK: Handler methods -extension ReadingView { - func setPageIndex(sliderValue: Float) { - let newValue = pageHandler.mapToPager( - index: .init(sliderValue), setting: setting + .readingViewModifiers( + store: store, + setting: $setting, + blurRadius: blurRadius ) - if page.index != newValue { - page.update(.new(index: newValue)) - Logger.info("Pager.update", context: ["update": newValue]) + .onAppear { + store.send(.onAppear(gid, setting.enablesLandscape)) + setupViewModels() } + .onDisappear { + cleanup() + } + .observeReadingChanges( + store: store, + setting: $setting, + viewModel: viewModel, + pageCoordinator: pageCoordinator, + page: page + ) } - func setAutoPlayPolocy(_ policy: AutoPlayPolicy) { - autoPlayHandler.setPolicy(policy, updatePageAction: { - page.update(.next) - Logger.info("Pager.update", context: ["update": "next"]) - }) + + // MARK: - Computed Properties + private var backgroundColor: Color { + colorScheme == .light ? Color(.systemGray4) : Color(.systemGray6) } - func analyzeImageForLiveText(index: Int) { - Logger.info("analyzeImageForLiveText", context: ["index": index]) - guard liveTextHandler.liveTextGroups[index] == nil else { - Logger.info("analyzeImageForLiveText duplicated", context: ["index": index]) - return - } - guard let key = store.imageURLs[index]?.absoluteString else { - Logger.info("analyzeImageForLiveText URL not found", context: ["index": index]) - return + + // MARK: - Helper Methods + private func setupViewModels() { + viewModel.setup(with: store.state, setting: setting) + gestureCoordinator.setup(setting: setting) + + // Setup page coordinator with initial reading progress if available + if store.readingProgress > 0 { + pageCoordinator.setup( + pageCount: store.gallery.pageCount, + setting: setting, + initialPage: store.readingProgress + ) + + // Also update the pager to the correct initial position + let pagerIndex = pageCoordinator.mapToPager(index: store.readingProgress, setting: setting) + page.update(.new(index: pagerIndex)) + } else { + pageCoordinator.setup( + pageCount: store.gallery.pageCount, + setting: setting + ) } - KingfisherManager.shared.cache.retrieveImage(forKey: key) { result in - switch result { - case .success(let result): - if let image = result.image, let cgImage = image.cgImage { - liveTextHandler.analyzeImage( - cgImage, size: image.size, index: index, recognitionLanguages: - store.galleryDetail?.language.codes + } + + private func cleanup() { + viewModel.cleanup() + gestureCoordinator.cleanup() + pageCoordinator.cleanup() + } +} + +// MARK: - Reading Content View +private struct ReadingContentView: View { + let store: StoreOf + @Binding var setting: Setting + @ObservedObject var viewModel: ReadingViewModel + @ObservedObject var gestureCoordinator: GestureCoordinator + @ObservedObject var pageCoordinator: PageCoordinator + let page: Page + + var body: some View { + Group { + if setting.readingDirection == .vertical { + VerticalReadingView( + store: store, + setting: $setting, + viewModel: viewModel, + gestureCoordinator: gestureCoordinator, + pageCoordinator: pageCoordinator, + page: page ) } else { - Logger.info("analyzeImageForLiveText image not found", context: ["index": index]) - } - case .failure(let error): - Logger.info( - "analyzeImageForLiveText failed", - context: [ - "index": index, - "error": error - ] - as [String: Any] + HorizontalReadingView( + store: store, + setting: $setting, + viewModel: viewModel, + gestureCoordinator: gestureCoordinator, + pageCoordinator: pageCoordinator, + page: page, + onTogglePanel: { store.send(.toggleShowsPanel) } ) } } + .scaleEffect(gestureCoordinator.scale, anchor: gestureCoordinator.scaleAnchor) + .offset(gestureCoordinator.offset) + .ignoresSafeArea() + .id(store.databaseLoadingState) + .id(store.forceRefreshID) } } -// MARK: Gesture -extension ReadingView { - var tapGesture: some Gesture { - let singleTap = TapGesture(count: 1) - .onEnded { - gestureHandler.onSingleTapGestureEnded( - readingDirection: setting.readingDirection, - setPageIndexOffsetAction: { - let newValue = page.index + $0 - page.update(.new(index: newValue)) - Logger.info("Pager.update", context: ["update": newValue]) - }, - toggleShowsPanelAction: { store.send(.toggleShowsPanel) } - ) - } - let doubleTap = TapGesture(count: 2) - .onEnded { - gestureHandler.onDoubleTapGestureEnded( - scaleMaximum: setting.maximumScaleFactor, - doubleTapScale: setting.doubleTapScaleFactor - ) - } - return ExclusiveGesture(doubleTap, singleTap) - } - var magnificationGesture: some Gesture { - MagnificationGesture() - .onChanged { - gestureHandler.onMagnificationGestureChanged( - value: $0, scaleMaximum: setting.maximumScaleFactor - ) - } - .onEnded { - gestureHandler.onMagnificationGestureEnded( - value: $0, scaleMaximum: setting.maximumScaleFactor - ) - } - } - var dragGesture: some Gesture { - DragGesture(minimumDistance: .zero, coordinateSpace: .local) - .onChanged(gestureHandler.onDragGestureChanged) - .onEnded(gestureHandler.onDragGestureEnded) - } - var controlPanelDismissGesture: some Gesture { - DragGesture().onEnded { - gestureHandler.onControlPanelDismissGestureEnded( - value: $0, dismissAction: { store.send(.onPerformDismiss) } +// MARK: - Vertical Reading View (Fixed for iOS 26) +private struct VerticalReadingView: View { + let store: StoreOf + @Binding var setting: Setting + @ObservedObject var viewModel: ReadingViewModel + @ObservedObject var gestureCoordinator: GestureCoordinator + @ObservedObject var pageCoordinator: PageCoordinator + let page: Page + + var body: some View { + // Fixed vertical scroll implementation for iOS 26 compatibility + ImprovedScrollView( + isScrollEnabled: gestureCoordinator.scale <= 1.0, + page: page, + data: store.state.containerDataSource(setting: setting), + spacing: setting.contentDividerHeight, + gestureCoordinator: gestureCoordinator, + pageCoordinator: pageCoordinator, + setting: setting, + onTogglePanel: { store.send(.toggleShowsPanel) } + ) { index in + ImageStackView( + index: index, + store: store, + setting: $setting, + viewModel: viewModel, + gestureCoordinator: gestureCoordinator ) } } } -// MARK: HorizontalImageStack -private struct HorizontalImageStack: View { - private let index: Int - private let isDualPage: Bool - private let isDatabaseLoading: Bool - private let backgroundColor: Color - private let config: ImageStackConfig - private let imageURLs: [Int: URL] - private let originalImageURLs: [Int: URL] - private let loadingStates: [Int: LoadingState] - private let enablesLiveText: Bool - private let liveTextGroups: [Int: [LiveTextGroup]] - private let focusedLiveTextGroup: LiveTextGroup? - private let liveTextTapAction: (LiveTextGroup) -> Void - private let fetchAction: (Int) -> Void - private let refetchAction: (Int) -> Void - private let prefetchAction: (Int) -> Void - private let loadRetryAction: (Int) -> Void - private let loadSucceededAction: (Int) -> Void - private let loadFailedAction: (Int) -> Void - private let copyImageAction: (URL) -> Void - private let saveImageAction: (URL) -> Void - private let shareImageAction: (URL) -> Void - - init( - index: Int, isDualPage: Bool, isDatabaseLoading: Bool, backgroundColor: Color, - config: ImageStackConfig, imageURLs: [Int: URL], originalImageURLs: [Int: URL], - loadingStates: [Int: LoadingState], enablesLiveText: Bool, - liveTextGroups: [Int: [LiveTextGroup]], focusedLiveTextGroup: LiveTextGroup?, - liveTextTapAction: @escaping (LiveTextGroup) -> Void, - fetchAction: @escaping (Int) -> Void, - refetchAction: @escaping (Int) -> Void, prefetchAction: @escaping (Int) -> Void, - loadRetryAction: @escaping (Int) -> Void, loadSucceededAction: @escaping (Int) -> Void, - loadFailedAction: @escaping (Int) -> Void, copyImageAction: @escaping (URL) -> Void, - saveImageAction: @escaping (URL) -> Void, shareImageAction: @escaping (URL) -> Void - ) { - self.index = index - self.isDualPage = isDualPage - self.isDatabaseLoading = isDatabaseLoading - self.backgroundColor = backgroundColor - self.config = config - self.imageURLs = imageURLs - self.originalImageURLs = originalImageURLs - self.loadingStates = loadingStates - self.enablesLiveText = enablesLiveText - self.liveTextGroups = liveTextGroups - self.focusedLiveTextGroup = focusedLiveTextGroup - self.liveTextTapAction = liveTextTapAction - self.fetchAction = fetchAction - self.refetchAction = refetchAction - self.prefetchAction = prefetchAction - self.loadRetryAction = loadRetryAction - self.loadSucceededAction = loadSucceededAction - self.loadFailedAction = loadFailedAction - self.copyImageAction = copyImageAction - self.saveImageAction = saveImageAction - self.shareImageAction = shareImageAction - } +// MARK: - Horizontal Reading View +private struct HorizontalReadingView: View { + let store: StoreOf + @Binding var setting: Setting + @ObservedObject var viewModel: ReadingViewModel + @ObservedObject var gestureCoordinator: GestureCoordinator + @ObservedObject var pageCoordinator: PageCoordinator + let page: Page + let onTogglePanel: () -> Void var body: some View { - HStack(spacing: 0) { - if config.isFirstAvailable { - imageContainer(index: config.firstIndex) - } - if config.isSecondAvailable { - imageContainer(index: config.secondIndex) - } + Pager( + page: page, + data: store.state.containerDataSource(setting: setting), + id: \.self + ) { index in + ImageStackView( + index: index, + store: store, + setting: $setting, + viewModel: viewModel, + gestureCoordinator: gestureCoordinator + ) } - } - - func imageContainer(index: Int) -> some View { - ImageContainer( - index: index, - imageURL: imageURLs[index], - loadingState: loadingStates[index] ?? .idle, - isDualPage: isDualPage, - backgroundColor: backgroundColor, - enablesLiveText: enablesLiveText, - liveTextGroups: liveTextGroups[index] ?? [], - focusedLiveTextGroup: focusedLiveTextGroup, - liveTextTapAction: liveTextTapAction, - refetchAction: refetchAction, - loadRetryAction: loadRetryAction, - loadSucceededAction: loadSucceededAction, - loadFailedAction: loadFailedAction + .horizontal(setting.readingDirection == .rightToLeft ? .endToStart : .startToEnd) + .swipeInteractionArea(.allAvailable) + .allowsDragging(gestureCoordinator.scale == 1) + .readingGestures( + gestureCoordinator: gestureCoordinator, + pageCoordinator: pageCoordinator, + setting: setting, + page: page, + onTogglePanel: onTogglePanel ) - .onAppear { - if !isDatabaseLoading { - if imageURLs[index] == nil { - fetchAction(index) - } - prefetchAction(index) - } - } - .contextMenu { contextMenuItems(index: index) } - } - @ViewBuilder private func contextMenuItems(index: Int) -> some View { - Button { - refetchAction(index) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.reload, systemSymbol: .arrowCounterclockwise) - } - if let imageURL = imageURLs[index] { - Button { - copyImageAction(imageURL) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.copy, systemSymbol: .plusSquareOnSquare) - } - Button { - saveImageAction(imageURL) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.save, systemSymbol: .squareAndArrowDown) - } - if let originalImageURL = originalImageURLs[index] { - Button { - saveImageAction(originalImageURL) - } label: { - Label( - L10n.Localizable.ReadingView.ContextMenu.Button.saveOriginal, - systemSymbol: .squareAndArrowDownOnSquare - ) - } - } - Button { - shareImageAction(imageURL) - } label: { - Label(L10n.Localizable.ReadingView.ContextMenu.Button.share, systemSymbol: .squareAndArrowUp) - } - } } } -// MARK: ImageContainer -private struct ImageContainer: View { - private var width: CGFloat { - DeviceUtil.windowW / (isDualPage ? 2 : 1) - } - private var height: CGFloat { - width / Defaults.ImageSize.contentAspect - } - - private let index: Int - private let imageURL: URL? - private let loadingState: LoadingState - private let isDualPage: Bool - private let backgroundColor: Color - private let enablesLiveText: Bool - private let liveTextGroups: [LiveTextGroup] - private let focusedLiveTextGroup: LiveTextGroup? - private let liveTextTapAction: (LiveTextGroup) -> Void - private let refetchAction: (Int) -> Void - private let loadRetryAction: (Int) -> Void - private let loadSucceededAction: (Int) -> Void - private let loadFailedAction: (Int) -> Void +// MARK: - Improved Scroll View (Fixes iOS 26 bug) +private struct ImprovedScrollView: View { + let isScrollEnabled: Bool + let page: Page + let data: [Int] + let spacing: CGFloat + let gestureCoordinator: GestureCoordinator + let pageCoordinator: PageCoordinator + let setting: Setting + let onTogglePanel: () -> Void + let content: (Int) -> Content + + @State private var performingChanges = false + @State private var scrollTarget: Int? + @State private var currentVisibleIndex: Int = 0 init( - index: Int, imageURL: URL?, - loadingState: LoadingState, - isDualPage: Bool, - backgroundColor: Color, - enablesLiveText: Bool, - liveTextGroups: [LiveTextGroup], - focusedLiveTextGroup: LiveTextGroup?, - liveTextTapAction: @escaping (LiveTextGroup) -> Void, - refetchAction: @escaping (Int) -> Void, - loadRetryAction: @escaping (Int) -> Void, - loadSucceededAction: @escaping (Int) -> Void, - loadFailedAction: @escaping (Int) -> Void + isScrollEnabled: Bool, + page: Page, + data: [Int], + spacing: CGFloat, + gestureCoordinator: GestureCoordinator, + pageCoordinator: PageCoordinator, + setting: Setting, + onTogglePanel: @escaping () -> Void, + @ViewBuilder content: @escaping (Int) -> Content ) { - self.index = index - self.imageURL = imageURL - self.loadingState = loadingState - self.isDualPage = isDualPage - self.backgroundColor = backgroundColor - self.enablesLiveText = enablesLiveText - self.liveTextGroups = liveTextGroups - self.focusedLiveTextGroup = focusedLiveTextGroup - self.liveTextTapAction = liveTextTapAction - self.refetchAction = refetchAction - self.loadRetryAction = loadRetryAction - self.loadSucceededAction = loadSucceededAction - self.loadFailedAction = loadFailedAction - } - - private func placeholder(_ progress: Progress) -> some View { - Placeholder(style: .progress( - pageNumber: index, progress: progress, - isDualPage: isDualPage, backgroundColor: backgroundColor - )) - .frame(width: width, height: height) - } - @ViewBuilder private func image(url: URL?) -> some View { - if url?.isGIF != true { - KFImage(url) - .placeholder(placeholder) - .defaultModifier(withRoundedCorners: false) - .onSuccess(onSuccess).onFailure(onFailure) - } else { - KFAnimatedImage(url) - .placeholder(placeholder).fade(duration: 0.25) - .onSuccess(onSuccess).onFailure(onFailure) - } + self.isScrollEnabled = isScrollEnabled + self.page = page + self.data = data + self.spacing = spacing + self.gestureCoordinator = gestureCoordinator + self.pageCoordinator = pageCoordinator + self.setting = setting + self.onTogglePanel = onTogglePanel + self.content = content } var body: some View { - if loadingState == .idle { - image(url: imageURL).scaledToFit().overlay( - LiveTextView( - liveTextGroups: liveTextGroups, - focusedLiveTextGroup: focusedLiveTextGroup, - tapAction: liveTextTapAction - ) - .opacity(enablesLiveText ? 1 : 0) + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: spacing) { + ForEach(data, id: \.self) { index in + content(index) + .id(index + 1) // Use 1-based indexing for scroll target + .background( + GeometryReader { geometry in + Color.clear + .preference( + key: ScrollOffsetPreferenceKey.self, + value: [index: ScrollOffsetData( + index: index, + frame: geometry.frame(in: .named("ScrollView")) + )] + ) + } + ) + } + } + .onAppear { + scrollToCurrentPage(proxy: proxy) + } + } + // Fixed scrollDisabled implementation for iOS 26 + .scrollDisabled(!isScrollEnabled) + .coordinateSpace(name: "ScrollView") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { preferences in + updateCurrentVisibleIndex(from: preferences) + } + .readingGestures( + gestureCoordinator: gestureCoordinator, + pageCoordinator: pageCoordinator, + setting: setting, + page: page, + onTogglePanel: onTogglePanel ) - } else { - ZStack { - backgroundColor - VStack { - Text(String(index)).font(.largeTitle.bold()) - .foregroundColor(.gray).padding(.bottom, 30) - ZStack { - Button(action: reloadImage) { - Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) + .onChange(of: page.index) { _, newValue in + scrollToPage(newValue, proxy: proxy) + } + .onChange(of: isScrollEnabled) { _, newValue in + // Re-enable/disable scrolling based on zoom level + if newValue && scrollTarget != nil { + if let target = scrollTarget { + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(target, anchor: .center) } - .font(.system(size: 30, weight: .medium)).foregroundColor(.gray) - .opacity(loadingState == .loading ? 0 : 1) - ProgressView().opacity(loadingState == .loading ? 1 : 0) + scrollTarget = nil } } } - .frame(width: width, height: height) } } - private func reloadImage() { - if let error = loadingState.failed { - if case .webImageFailed = error { - loadRetryAction(index) - } else { - refetchAction(index) + + private func updateCurrentVisibleIndex(from preferences: [Int: ScrollOffsetData]) { + guard !performingChanges else { return } + + // Find the most visible item (closest to center of screen) + let screenCenter = UIScreen.main.bounds.height / 2 + var mostVisibleIndex = 0 + var maxVisibility: CGFloat = 0 + + for (_, item) in preferences { + let itemCenter = item.frame.midY + let distanceFromCenter = abs(itemCenter - screenCenter) + let visibility = max(0, 1 - distanceFromCenter / screenCenter) + + if visibility > maxVisibility { + maxVisibility = visibility + mostVisibleIndex = item.index } } + + // Update page index if it changed significantly + if mostVisibleIndex != currentVisibleIndex && maxVisibility > 0.5 { + currentVisibleIndex = mostVisibleIndex + let newPageIndex = mostVisibleIndex + if page.index != newPageIndex { + performingChanges = true + page.update(.new(index: newPageIndex)) + + // Reset performing changes after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + performingChanges = false + } + + Logger.info("Updated page index from scroll", context: [ + "newPageIndex": newPageIndex, + "visibility": maxVisibility + ]) + } + } + } + + private func handleTap(index: Int) { + performingChanges = true + page.update(.new(index: index - 1)) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + performingChanges = false + } } - private func onSuccess(_: RetrieveImageResult) { - loadSucceededAction(index) + + private func scrollToCurrentPage(proxy: ScrollViewProxy) { + let targetId = page.index + 1 + DispatchQueue.main.async { + proxy.scrollTo(targetId, anchor: .center) + } } - private func onFailure(_: KingfisherError) { - if imageURL != nil { - loadFailedAction(index) + + private func scrollToPage(_ pageIndex: Int, proxy: ScrollViewProxy) { + guard !performingChanges else { return } + + let targetId = pageIndex + 1 + if isScrollEnabled { + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(targetId, anchor: .center) + } + } else { + // Store target for when scrolling is re-enabled + scrollTarget = targetId } } } -// MARK: Definition -struct ImageStackConfig { - let firstIndex: Int - let secondIndex: Int - let isFirstAvailable: Bool - let isSecondAvailable: Bool +// MARK: - Scroll Position Tracking +private struct ScrollOffsetData: Equatable { + let index: Int + let frame: CGRect } -enum AutoPlayPolicy: Int, CaseIterable, Identifiable { - var id: Int { rawValue } - - case off = -1 - case sec1 = 1 - case sec2 = 2 - case sec3 = 3 - case sec4 = 4 - case sec5 = 5 +private struct ScrollOffsetPreferenceKey: PreferenceKey { + static var defaultValue: [Int: ScrollOffsetData] = [:] + + static func reduce(value: inout [Int: ScrollOffsetData], nextValue: () -> [Int: ScrollOffsetData]) { + value.merge(nextValue()) { _, new in new } + } } -extension AutoPlayPolicy { - var value: String { - switch self { - case .off: - return L10n.Localizable.Enum.AutoPlayPolicy.Value.off - default: - return L10n.Localizable.Common.Value.seconds("\(rawValue)") - } +// MARK: - Reading Controls Overlay +private struct ReadingControlsOverlay: View { + let store: StoreOf + @Binding var setting: Setting + @ObservedObject var viewModel: ReadingViewModel + @ObservedObject var pageCoordinator: PageCoordinator + @ObservedObject var gestureCoordinator: GestureCoordinator + let page: Page + + var body: some View { + ReadingControlPanel( + showsPanel: Binding( + get: { store.showsPanel }, + set: { store.send(.binding(.set(\.showsPanel, $0))) } + ), + showsSliderPreview: Binding( + get: { store.showsSliderPreview }, + set: { store.send(.binding(.set(\.showsSliderPreview, $0))) } + ), + sliderValue: $pageCoordinator.sliderValue, + setting: $setting, + enablesLiveText: $viewModel.enablesLiveText, + autoPlayPolicy: .init( + get: { viewModel.autoPlayPolicy }, + set: { viewModel.setAutoPlayPolicy($0, pageUpdater: { + page.update(.next) + }) } + ), + range: 1...Float(store.gallery.pageCount), + previewURLs: store.previewURLs, + dismissGesture: createDismissGesture(), + dismissAction: { store.send(.onPerformDismiss) }, + navigateSettingAction: { store.send(.setNavigation(.readingSetting())) }, + reloadAllImagesAction: { store.send(.reloadAllWebImages) }, + retryAllFailedImagesAction: { store.send(.retryAllFailedWebImages) }, + fetchPreviewURLsAction: { store.send(.fetchPreviewURLs($0)) } + ) + } + + private func createDismissGesture() -> some Gesture { + DragGesture() + .onEnded { value in + gestureCoordinator.handleControlPanelDismiss( + value: value, + dismissAction: { store.send(.onPerformDismiss) } + ) + } } } +// MARK: - Preview struct ReadingView_Previews: PreviewProvider { static var previews: some View { NavigationView { Text("") .fullScreenCover(isPresented: .constant(true)) { ReadingView( - store: .init(initialState: .init(gallery: .empty), reducer: ReadingReducer.init), + store: .init( + initialState: .init(gallery: .empty), + reducer: ReadingReducer.init + ), gid: .init(), setting: .constant(.init()), blurRadius: 0 diff --git a/EhPanda/View/Reading/Support/AdvancedList.swift b/EhPanda/View/Reading/Support/AdvancedList.swift index 4adb6f52..c2aec933 100644 --- a/EhPanda/View/Reading/Support/AdvancedList.swift +++ b/EhPanda/View/Reading/Support/AdvancedList.swift @@ -3,25 +3,35 @@ // EhPanda // // Created by 荒木辰造 on R 3/07/30. +// Improved architecture by zackie on 2025-07-28. // import SwiftUI import SwiftUIPager +/// Improved vertical list for reading view with iOS 26 scrolling fix struct AdvancedList: View where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { - @State var performingChanges = false - + + // MARK: - State + @State private var performingChanges = false + @State private var scrollTarget: Element? + + // MARK: - Properties private let pagerModel: Page private let data: [Element] private let id: KeyPath private let spacing: CGFloat private let gesture: G private let content: (Element) -> PageView - + + // MARK: - Initialization init( - page: Page, data: Data, - id: KeyPath, spacing: CGFloat, gesture: G, + page: Page, + data: Data, + id: KeyPath, + spacing: CGFloat, + gesture: G, @ViewBuilder content: @escaping (Element) -> PageView ) where Data.Index == Int, Data.Element == Element { self.pagerModel = page @@ -31,43 +41,172 @@ where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { self.gesture = gesture self.content = content } - + + // MARK: - Body var body: some View { ScrollViewReader { proxy in - ScrollView(showsIndicators: false) { + ScrollView(.vertical, showsIndicators: false) { LazyVStack(spacing: spacing) { - ForEach(data, id: id) { index in - let longPress = longPressGesture(index: index) - let gestures = longPress.simultaneously(with: gesture) - content(index).gesture(gestures) + ForEach(data, id: id) { element in + contentWithGestures(for: element) + .id(element[keyPath: id]) } } - .onAppear { tryScrollTo(id: pagerModel.index + 1, proxy: proxy) } + .onAppear { + initialScrollToPage(proxy: proxy) + } } + // iOS 26 compatible scroll handling + .coordinateSpace(name: "ScrollView") .onChange(of: pagerModel.index) { _, newValue in - tryScrollTo(id: newValue + 1, proxy: proxy) + handlePageChange(newValue: newValue, proxy: proxy) + } + .onChange(of: scrollTarget) { _, newValue in + if let target = newValue { + scrollToTarget(target, proxy: proxy) + } } } } - - private func longPressGesture(index: Element) -> some Gesture { + + // MARK: - Content with Gestures + @ViewBuilder + private func contentWithGestures(for element: Element) -> some View { + let longPress = createLongPressGesture(for: element) + let combinedGestures = longPress.simultaneously(with: gesture) + + content(element) + .gesture(combinedGestures) + } + + // MARK: - Gesture Creation + private func createLongPressGesture(for element: Element) -> some Gesture { LongPressGesture(minimumDuration: 0, maximumDistance: .infinity) .onEnded { _ in - if let index = index as? Int { - performingChanges = true - pagerModel.update(.new(index: index - 1)) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - performingChanges = false + handleLongPress(for: element) + } + } + + // MARK: - Event Handlers + private func handleLongPress(for element: Element) { + guard let index = element as? Int else { return } + + Logger.info("Long press detected", context: ["element": index]) + + performingChanges = true + pagerModel.update(.new(index: index - 1)) + + // Reset performing changes after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + performingChanges = false + } + } + + private func initialScrollToPage(proxy: ScrollViewProxy) { + guard !data.isEmpty else { return } + + let targetElement = getElementForPageIndex(pagerModel.index) + scrollToElementSafely(targetElement, proxy: proxy, animated: false) + } + + private func handlePageChange(newValue: Int, proxy: ScrollViewProxy) { + guard !performingChanges else { return } + + Logger.info("Page changed in AdvancedList", context: [ + "newPageIndex": newValue, + "dataCount": data.count + ]) + + let targetElement = getElementForPageIndex(newValue) + scrollToElementSafely(targetElement, proxy: proxy, animated: true) + } + + private func scrollToTarget(_ target: Element, proxy: ScrollViewProxy) { + scrollToElementSafely(target, proxy: proxy, animated: true) + scrollTarget = nil + } + + // MARK: - Helper Methods + private func getElementForPageIndex(_ pageIndex: Int) -> Element? { + let safeIndex = max(0, min(pageIndex, data.count - 1)) + guard safeIndex < data.count else { return nil } + return data[safeIndex] + } + + private func scrollToElementSafely( + _ element: Element?, + proxy: ScrollViewProxy, + animated: Bool + ) { + guard let element = element else { return } + + let elementId = element[keyPath: id] + + if animated { + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(elementId, anchor: .center) + } + } else { + // Use dispatchMainSync for immediate scrolling without animation + AppUtil.dispatchMainSync { + proxy.scrollTo(elementId, anchor: .center) + } + } + + Logger.info("Scrolled to element", context: [ + "elementId": "\(elementId)", + "animated": animated + ]) + } +} + +// MARK: - iOS 26 Compatibility Extensions + +extension AdvancedList { + /// Creates a version with enhanced scroll compatibility + func withEnhancedScrolling() -> some View { + self + .scrollContentBackground(.hidden) + .scrollIndicators(.hidden) + } + + /// Handles scroll position restoration for iOS 26 + func withScrollRestoration() -> some View { + self + .onAppear { + // Ensure proper scroll position on appear + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if let currentElement = getElementForPageIndex(pagerModel.index) { + scrollTarget = currentElement } } } } +} - private func tryScrollTo(id: Int, proxy: ScrollViewProxy) { - if !performingChanges { - AppUtil.dispatchMainSync { - proxy.scrollTo(id, anchor: .center) - } +// MARK: - Preview +struct AdvancedList_Previews: PreviewProvider { + static var previews: some View { + let page = Page.first() + let sampleData = Array(1...10) + + AdvancedList( + page: page, + data: sampleData, + id: \.self, + spacing: 10, + gesture: TapGesture() + ) { item in + Rectangle() + .fill(Color.blue.opacity(0.3)) + .frame(height: 200) + .overlay( + Text("\(item)") + .font(.title) + .foregroundColor(.primary) + ) } + .previewLayout(.sizeThatFits) } } + diff --git a/EhPanda/View/Reading/Support/AutoPlayHandler.swift b/EhPanda/View/Reading/Support/AutoPlayHandler.swift deleted file mode 100644 index c45f6027..00000000 --- a/EhPanda/View/Reading/Support/AutoPlayHandler.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// AutoPlayHandler.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/02/09. -// - -import SwiftUI - -final class AutoPlayHandler: ObservableObject { - @Published var policy: AutoPlayPolicy = .off - private var timer: Timer? - - deinit { - invalidate() - } - - func invalidate() { - Logger.info("invalidate") - timer?.invalidate() - } - - func setPolicy(_ policy: AutoPlayPolicy, updatePageAction: @escaping () -> Void) { - Logger.info("setPolicy", context: ["policy": policy]) - self.policy = policy - timer?.invalidate() - let timeInterval = TimeInterval(policy.rawValue) - if timeInterval > 0 { - timer = .scheduledTimer( - withTimeInterval: timeInterval, repeats: true, - block: { _ in updatePageAction() } - ) - } - } -} diff --git a/EhPanda/View/Reading/Support/GestureCoordinator.swift b/EhPanda/View/Reading/Support/GestureCoordinator.swift new file mode 100644 index 00000000..521c5603 --- /dev/null +++ b/EhPanda/View/Reading/Support/GestureCoordinator.swift @@ -0,0 +1,390 @@ +// +// GestureCoordinator.swift +// EhPanda +// +// Created by zackie on 2025-07-28 for improved Reading view architecture +// + +import SwiftUI +import SwiftUIPager + +// MARK: - Gesture Coordinator +final class GestureCoordinator: ObservableObject { + // MARK: - Published Properties + @Published var scaleAnchor: UnitPoint = .center + @Published var scale: Double = 1.0 + @Published var offset: CGSize = .zero + @Published var dragStartOffset: CGSize = .zero + + // MARK: - Private Properties + private var baseScale: Double = 1.0 + private var baseOffset: CGSize = .zero + private var currentPanOffset: CGSize = .zero + private var setting: Setting = .init() + + // MARK: - Configuration + private var gestureConfig: GestureConfiguration = .init() + + // MARK: - Setup + func setup(setting: Setting) { + self.setting = setting + gestureConfig = GestureConfiguration(setting: setting) + } + + func cleanup() { + resetToDefaults() + } + + private func resetToDefaults() { + scale = 1.0 + offset = .zero + scaleAnchor = .center + baseScale = 1.0 + baseOffset = .zero + } + + // MARK: - Gesture Handlers + + /// Handles single tap gestures for page navigation or panel toggling + func handleSingleTap( + readingDirection: ReadingDirection, + onPageNavigation: @escaping (Int) -> Void, + onTogglePanel: @escaping () -> Void + ) { + Logger.info("Handle single tap", context: ["readingDirection": readingDirection]) + + // For vertical reading, always toggle panel + guard readingDirection != .vertical, + let touchPoint = TouchHandler.shared.currentPoint + else { + onTogglePanel() + return + } + + let tapRegion = determineTapRegion(point: touchPoint) + handleTapRegion(tapRegion, readingDirection: readingDirection, onPageNavigation: onPageNavigation, onTogglePanel: onTogglePanel) + } + + /// Handles double tap gestures for zoom + func handleDoubleTap() { + Logger.info("Handle double tap", context: [ + "currentScale": scale, + "doubleTapScale": setting.doubleTapScaleFactor + ]) + + let targetScale = scale == 1.0 ? setting.doubleTapScaleFactor : 1.0 + + if let touchPoint = TouchHandler.shared.currentPoint { + updateScaleAnchor(for: touchPoint) + } + + withAnimation(.easeInOut(duration: 0.25)) { + scale = targetScale + if targetScale == 1.0 { + offset = .zero + scaleAnchor = .center + } + } + + baseScale = scale + baseOffset = offset + } + + /// Handles magnification (pinch) gestures + func handleMagnificationChanged(value: Double) { + Logger.info("Handle magnification changed", context: ["value": value]) + + if value == 1.0 { + baseScale = scale + } + + if let touchPoint = TouchHandler.shared.currentPoint { + updateScaleAnchor(for: touchPoint) + } + + let newScale = min(max(value * baseScale, 1.0), setting.maximumScaleFactor) + scale = newScale + constrainOffset() + } + + func handleMagnificationEnded(value: Double) { + Logger.info("Handle magnification ended", context: ["value": value]) + + let finalScale = min(max(value * baseScale, 1.0), setting.maximumScaleFactor) + + // Snap to 1.0 if very close + if abs(finalScale - 1.0) < 0.05 { + withAnimation(.easeOut(duration: 0.2)) { + scale = 1.0 + offset = .zero + scaleAnchor = .center + } + } else { + scale = finalScale + constrainOffset() + } + + baseScale = scale + baseOffset = offset + } + + /// Handles drag gestures for panning when zoomed + func handleDragChanged(value: DragGesture.Value) { + guard scale > 1.0 else { return } + + Logger.info("Handle drag changed", context: [ + "translation": value.translation, + "scale": scale, + "currentPanOffset": currentPanOffset + ]) + + // Add high sensitivity multiplier for more responsive movement + let sensitivity: CGFloat = 2.0 + let adjustedTranslation = CGSize( + width: value.translation.width * sensitivity, + height: value.translation.height * sensitivity + ) + + // Update current pan offset + currentPanOffset = adjustedTranslation + + // Calculate total offset (base + current pan) + let totalOffset = CGSize( + width: baseOffset.width + currentPanOffset.width, + height: baseOffset.height + currentPanOffset.height + ) + + // Temporarily remove constraints for testing + offset = totalOffset + + Logger.info("Offset updated", context: [ + "adjustedTranslation": adjustedTranslation, + "currentPanOffset": currentPanOffset, + "totalOffset": totalOffset, + "offset": offset + ]) + } + + func handleDragStarted() { + guard scale > 1.0 else { return } + Logger.info("Handle drag started") + currentPanOffset = .zero + } + + func handleDragEnded(value: DragGesture.Value) { + guard scale > 1.0 else { return } + Logger.info("Handle drag ended") + + // Update base offset with final position + baseOffset = offset + currentPanOffset = .zero + } + + /// Handles control panel dismiss gesture + func handleControlPanelDismiss(value: DragGesture.Value, dismissAction: @escaping () -> Void) { + Logger.info("Handle control panel dismiss", context: ["translation": value.translation]) + + if value.predictedEndTranslation.height > 30 { + dismissAction() + } + } + + // MARK: - Private Helper Methods + + private func determineTapRegion(point: CGPoint) -> TapRegion { + let screenWidth = DeviceUtil.absWindowW + let leftThreshold = screenWidth * 0.2 + let rightThreshold = screenWidth * 0.8 + + if point.x < leftThreshold { + return .left + } else if point.x > rightThreshold { + return .right + } else { + return .center + } + } + + private func handleTapRegion( + _ region: TapRegion, + readingDirection: ReadingDirection, + onPageNavigation: @escaping (Int) -> Void, + onTogglePanel: @escaping () -> Void + ) { + let isRightToLeft = readingDirection == .rightToLeft + + switch region { + case .left: + onPageNavigation(isRightToLeft ? 1 : -1) + case .right: + onPageNavigation(isRightToLeft ? -1 : 1) + case .center: + onTogglePanel() + } + } + + private func updateScaleAnchor(for point: CGPoint) { + let normalizedX = min(1, max(0, point.x / DeviceUtil.absWindowW)) + let normalizedY = min(1, max(0, point.y / DeviceUtil.absWindowH)) + scaleAnchor = UnitPoint(x: normalizedX, y: normalizedY) + } + + @discardableResult + private func constrainOffset(_ newOffset: CGSize? = nil) -> CGSize { + let targetOffset = newOffset ?? offset + + let constrainedWidth = constrainOffsetDimension( + value: targetOffset.width, + anchor: scaleAnchor.x, + screenSize: DeviceUtil.absWindowW + ) + + let constrainedHeight = constrainOffsetDimension( + value: targetOffset.height, + anchor: scaleAnchor.y, + screenSize: DeviceUtil.absWindowH + ) + + let constrained = CGSize(width: constrainedWidth, height: constrainedHeight) + + if newOffset == nil { + offset = constrained + } + + return constrained + } + + private func constrainOffsetDimension( + value: Double, + anchor: Double, + screenSize: Double + ) -> Double { + let margin = screenSize * (scale - 1) / 2 + let leadingMargin = (anchor / 0.5) * margin + let trailingMargin = ((1 - anchor) / 0.5) * margin + + return min(max(value, -trailingMargin), leadingMargin) + } + + private func constrainOffsetSimple(_ newOffset: CGSize) -> CGSize { + let screenWidth = DeviceUtil.absWindowW + let screenHeight = DeviceUtil.absWindowH + + // Calculate maximum allowed offset based on zoom level with more flexibility + let maxOffsetX = screenWidth * (scale - 1) * 0.8 // Allow 80% of theoretical max + let maxOffsetY = screenHeight * (scale - 1) * 0.8 + + // Apply bounds with more flexibility for natural panning + let constrainedWidth = min(max(newOffset.width, -maxOffsetX), maxOffsetX) + let constrainedHeight = min(max(newOffset.height, -maxOffsetY), maxOffsetY) + + return CGSize(width: constrainedWidth, height: constrainedHeight) + } +} + +// MARK: - Supporting Types + +private enum TapRegion { + case left, center, right +} + +private struct GestureConfiguration { + let tapRegionThreshold: Double + let snapToOneThreshold: Double + let panVelocityThreshold: Double + + init(setting: Setting? = nil) { + self.tapRegionThreshold = 0.2 + self.snapToOneThreshold = 0.05 + self.panVelocityThreshold = 100.0 + } +} + +// MARK: - View Extensions for Gesture Support + +extension View { + func readingGestures( + gestureCoordinator: GestureCoordinator, + pageCoordinator: PageCoordinator, + setting: Setting, + page: Page, + onTogglePanel: @escaping () -> Void + ) -> some View { + let tapGesture = createTapGesture( + gestureCoordinator: gestureCoordinator, + pageCoordinator: pageCoordinator, + setting: setting, + page: page, + onTogglePanel: onTogglePanel + ) + + let magnificationGesture = createMagnificationGesture( + gestureCoordinator: gestureCoordinator + ) + + let dragGesture = createDragGesture( + gestureCoordinator: gestureCoordinator + ) + + return self + .gesture(dragGesture, isEnabled: gestureCoordinator.scale > 1) + .simultaneousGesture( + tapGesture, + isEnabled: gestureCoordinator.scale > 1 + ) + .gesture(tapGesture, isEnabled: gestureCoordinator.scale == 1) + .gesture(magnificationGesture) + } + + private func createTapGesture( + gestureCoordinator: GestureCoordinator, + pageCoordinator: PageCoordinator, + setting: Setting, + page: Page, + onTogglePanel: @escaping () -> Void + ) -> some Gesture { + let singleTap = TapGesture(count: 1) + .onEnded { + gestureCoordinator.handleSingleTap( + readingDirection: setting.readingDirection, + onPageNavigation: { offset in + let newIndex = page.index + offset + page.update(.new(index: newIndex)) + Logger.info("Page navigation", context: ["newIndex": newIndex]) + }, + onTogglePanel: onTogglePanel + ) + } + + let doubleTap = TapGesture(count: 2) + .onEnded { + gestureCoordinator.handleDoubleTap() + } + + return ExclusiveGesture(doubleTap, singleTap) + } + + private func createMagnificationGesture( + gestureCoordinator: GestureCoordinator + ) -> some Gesture { + MagnificationGesture() + .onChanged { value in + gestureCoordinator.handleMagnificationChanged(value: value) + } + .onEnded { value in + gestureCoordinator.handleMagnificationEnded(value: value) + } + } + + private func createDragGesture( + gestureCoordinator: GestureCoordinator + ) -> some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .global) + .onChanged { value in + gestureCoordinator.handleDragChanged(value: value) + } + .onEnded { value in + gestureCoordinator.handleDragEnded(value: value) + } + } +} \ No newline at end of file diff --git a/EhPanda/View/Reading/Support/GestureHandler.swift b/EhPanda/View/Reading/Support/GestureHandler.swift deleted file mode 100644 index af9d5916..00000000 --- a/EhPanda/View/Reading/Support/GestureHandler.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// GestureHandler.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/02/09. -// - -import SwiftUI - -final class GestureHandler: ObservableObject { - @Published var scaleAnchor: UnitPoint = .center - @Published var scale: Double = 1 - @Published var offset: CGSize = .zero - @Published private var baseScale: Double = 1 - @Published private var newOffset: CGSize = .zero - - private func edgeWidth(x: Double) -> Double { - let marginW = DeviceUtil.absWindowW * (scale - 1) / 2 - let leadingMargin = scaleAnchor.x / 0.5 * marginW - let trailingMargin = (1 - scaleAnchor.x) / 0.5 * marginW - return min(max(x, -trailingMargin), leadingMargin) - } - private func edgeHeight(y: Double) -> Double { - let marginH = DeviceUtil.absWindowH * (scale - 1) / 2 - let topMargin = scaleAnchor.y / 0.5 * marginH - let bottomMargin = (1 - scaleAnchor.y) / 0.5 * marginH - return min(max(y, -bottomMargin), topMargin) - } - private func correctOffset() { - offset.width = edgeWidth(x: offset.width) - offset.height = edgeHeight(y: offset.height) - } - private func correctScaleAnchor(point: CGPoint) { - let x = min(1, max(0, point.x / DeviceUtil.absWindowW)) - let y = min(1, max(0, point.y / DeviceUtil.absWindowH)) - scaleAnchor = .init(x: x, y: y) - } - private func setOffset(_ offset: CGSize) { - self.offset = offset - correctOffset() - } - private func setScale(scale: Double, maximum: Double) { - guard scale >= 1 && scale <= maximum else { return } - self.scale = scale - correctOffset() - } - - func onSingleTapGestureEnded( - readingDirection: ReadingDirection, - setPageIndexOffsetAction: @escaping (Int) -> Void, - toggleShowsPanelAction: @escaping () -> Void - ) { - Logger.info("onSingleTapGestureEnded", context: ["readingDirection": readingDirection]) - guard readingDirection != .vertical, - let pointX = TouchHandler.shared.currentPoint?.x - else { - toggleShowsPanelAction() - return - } - let rightToLeft = readingDirection == .rightToLeft - if pointX < DeviceUtil.absWindowW * 0.2 { - setPageIndexOffsetAction(rightToLeft ? 1 : -1) - } else if pointX > DeviceUtil.absWindowW * (1 - 0.2) { - setPageIndexOffsetAction(rightToLeft ? -1 : 1) - } else { - toggleShowsPanelAction() - } - } - - func onDoubleTapGestureEnded(scaleMaximum: Double, doubleTapScale: Double) { - Logger.info("onDoubleTapGestureEnded", context: [ - "scaleMaximum": scaleMaximum, "doubleTapScale": doubleTapScale - ]) - let newScale = scale == 1 ? doubleTapScale : 1 - if let point = TouchHandler.shared.currentPoint { - correctScaleAnchor(point: point) - } - setOffset(.zero) - setScale(scale: newScale, maximum: scaleMaximum) - } - - func onMagnificationGestureChanged(value: Double, scaleMaximum: Double) { - Logger.info("onMagnificationGestureChanged", context: [ - "value": value, "scaleMaximum": scaleMaximum - ]) - if value == 1 { - baseScale = scale - } - if let point = TouchHandler.shared.currentPoint { - correctScaleAnchor(point: point) - } - setScale(scale: value * baseScale, maximum: scaleMaximum) - } - - func onMagnificationGestureEnded(value: Double, scaleMaximum: Double) { - Logger.info("onMagnificationGestureEnded", context: [ - "value": value, "scaleMaximum": scaleMaximum - ]) - onMagnificationGestureChanged(value: value, scaleMaximum: scaleMaximum) - if value * baseScale - 1 < 0.01 { - setScale(scale: 1, maximum: scaleMaximum) - } - baseScale = scale - } - - func onDragGestureChanged(value: DragGesture.Value) { - Logger.info("onDragGestureChanged", context: ["value": value]) - guard scale > 1 else { return } - let newX = value.translation.width + newOffset.width - let newY = value.translation.height + newOffset.height - let newOffsetW = edgeWidth(x: newX) - let newOffsetH = edgeHeight(y: newY) - setOffset(.init(width: newOffsetW, height: newOffsetH)) - } - - func onDragGestureEnded(value: DragGesture.Value) { - Logger.info("onDragGestureEnded", context: ["value": value]) - onDragGestureChanged(value: value) - if scale > 1 { - newOffset.width = offset.width - newOffset.height = offset.height - } - } - - func onControlPanelDismissGestureEnded(value: DragGesture.Value, dismissAction: @escaping () -> Void) { - Logger.info("onControlPanelDismissGestureEnded", context: ["value": value]) - if value.predictedEndTranslation.height > 30 { - dismissAction() - } - } -} diff --git a/EhPanda/View/Reading/Support/ImageStackView.swift b/EhPanda/View/Reading/Support/ImageStackView.swift new file mode 100644 index 00000000..97f0f7b3 --- /dev/null +++ b/EhPanda/View/Reading/Support/ImageStackView.swift @@ -0,0 +1,349 @@ +// +// ImageStackView.swift +// EhPanda +// +// Created by zackie on 2025-07-28 for improved Reading view architecture +// + +import SwiftUI +import Kingfisher +import ComposableArchitecture + +// MARK: - Image Stack View +struct ImageStackView: View { + // MARK: - Properties + private let index: Int + private let store: StoreOf + @Binding private var setting: Setting + @ObservedObject private var viewModel: ReadingViewModel + @ObservedObject private var gestureCoordinator: GestureCoordinator + + // MARK: - Computed Properties + private var isDualPage: Bool { + setting.enablesDualPageMode && + setting.readingDirection != .vertical && + DeviceUtil.isLandscape + } + + private var backgroundColor: Color { + Color(.systemGray4) // This should match the main view's background + } + + private var imageStackConfig: ImageStackConfig { + let dualPageConfig = pageCoordinator.getDualPageConfiguration( + for: index, + setting: setting + ) + return ImageStackConfig(from: dualPageConfig) + } + + // MARK: - Dependencies + private var pageCoordinator: PageCoordinator { + // This would ideally be injected, but for now we create a temporary one + let coordinator = PageCoordinator() + coordinator.setup(pageCount: store.gallery.pageCount, setting: setting) + return coordinator + } + + // MARK: - Initialization + init( + index: Int, + store: StoreOf, + setting: Binding, + viewModel: ReadingViewModel, + gestureCoordinator: GestureCoordinator + ) { + self.index = index + self.store = store + _setting = setting + self.viewModel = viewModel + self.gestureCoordinator = gestureCoordinator + } + + // MARK: - Body + var body: some View { + HStack(spacing: 0) { + if imageStackConfig.isFirstAvailable { + ImageContainerView( + index: imageStackConfig.firstIndex, + store: store, + setting: $setting, + viewModel: viewModel, + isDualPage: isDualPage, + backgroundColor: backgroundColor + ) + } + + if imageStackConfig.isSecondAvailable { + ImageContainerView( + index: imageStackConfig.secondIndex, + store: store, + setting: $setting, + viewModel: viewModel, + isDualPage: isDualPage, + backgroundColor: backgroundColor + ) + } + } + } +} + +// MARK: - Image Container View +private struct ImageContainerView: View { + // MARK: - Properties + private let index: Int + private let store: StoreOf + @Binding private var setting: Setting + @ObservedObject private var viewModel: ReadingViewModel + private let isDualPage: Bool + private let backgroundColor: Color + + // MARK: - Computed Properties + private var imageURL: URL? { + store.imageURLs[index] + } + + private var originalImageURL: URL? { + store.originalImageURLs[index] + } + + private var loadingState: LoadingState { + store.imageURLLoadingStates[index] ?? .idle + } + + private var liveTextGroups: [LiveTextGroup] { + viewModel.liveTextGroups[index] ?? [] + } + + private var containerSize: CGSize { + let width = DeviceUtil.windowW / (isDualPage ? 2 : 1) + let height = width / Defaults.ImageSize.contentAspect + return CGSize(width: width, height: height) + } + + // MARK: - Initialization + init( + index: Int, + store: StoreOf, + setting: Binding, + viewModel: ReadingViewModel, + isDualPage: Bool, + backgroundColor: Color + ) { + self.index = index + self.store = store + _setting = setting + self.viewModel = viewModel + self.isDualPage = isDualPage + self.backgroundColor = backgroundColor + } + + // MARK: - Body + var body: some View { + Group { + if loadingState == .idle { + successView + } else { + loadingOrErrorView + } + } + .onAppear { + handleAppear() + } + .contextMenu { + contextMenuItems + } + } + + // MARK: - Success View + private var successView: some View { + ZStack { + imageView + .scaledToFit() + .overlay( + LiveTextView( + liveTextGroups: liveTextGroups, + focusedLiveTextGroup: viewModel.focusedLiveTextGroup, + tapAction: viewModel.setFocusedLiveTextGroup + ) + .opacity(viewModel.enablesLiveText ? 1 : 0) + ) + } + } + + // MARK: - Image View + @ViewBuilder + private var imageView: some View { + if let url = imageURL { + if url.isGIF { + KFAnimatedImage(url) + .placeholder { placeholderView() } + .fade(duration: 0.25) + .onSuccess { _ in handleImageSuccess() } + .onFailure { _ in handleImageFailure() } + } else { + KFImage(url) + .placeholder { placeholderView() } + .defaultModifier(withRoundedCorners: false) + .onSuccess { _ in handleImageSuccess() } + .onFailure { _ in handleImageFailure() } + } + } else { + placeholderView(Progress()) + } + } + + // MARK: - Placeholder View + private func placeholderView(_ progress: Progress = Progress()) -> some View { + Placeholder( + style: .progress( + pageNumber: index, + progress: progress, + isDualPage: isDualPage, + backgroundColor: backgroundColor + ) + ) + .frame(width: containerSize.width, height: containerSize.height) + } + + // MARK: - Loading/Error View + private var loadingOrErrorView: some View { + ZStack { + backgroundColor + + VStack(spacing: 30) { + Text("\(index)") + .font(.largeTitle.bold()) + .foregroundColor(.gray) + + ZStack { + if loadingState == .loading { + ProgressView() + } else { + Button(action: handleReloadTap) { + Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) + .font(.system(size: 30, weight: .medium)) + .foregroundColor(.gray) + } + } + } + } + } + .frame(width: containerSize.width, height: containerSize.height) + } + + // MARK: - Context Menu + @ViewBuilder + private var contextMenuItems: some View { + Button(action: handleRefetch) { + Label( + L10n.Localizable.ReadingView.ContextMenu.Button.reload, + systemSymbol: .arrowCounterclockwise + ) + } + + if let imageURL = imageURL { + Button(action: { handleCopyImage(imageURL) }) { + Label( + L10n.Localizable.ReadingView.ContextMenu.Button.copy, + systemSymbol: .plusSquareOnSquare + ) + } + + Button(action: { handleSaveImage(imageURL) }) { + Label( + L10n.Localizable.ReadingView.ContextMenu.Button.save, + systemSymbol: .squareAndArrowDown + ) + } + + if let originalImageURL = originalImageURL { + Button(action: { handleSaveImage(originalImageURL) }) { + Label( + L10n.Localizable.ReadingView.ContextMenu.Button.saveOriginal, + systemSymbol: .squareAndArrowDownOnSquare + ) + } + } + + Button(action: { handleShareImage(imageURL) }) { + Label( + L10n.Localizable.ReadingView.ContextMenu.Button.share, + systemSymbol: .squareAndArrowUp + ) + } + } + } + + // MARK: - Event Handlers + private func handleAppear() { + let isDatabaseLoading = store.databaseLoadingState != .idle + + if !isDatabaseLoading { + if imageURL == nil { + store.send(.fetchImageURLs(index)) + } + store.send(.prefetchImages(index, setting.prefetchLimit)) + } + } + + private func handleImageSuccess() { + store.send(.onWebImageSucceeded(index)) + + if viewModel.enablesLiveText { + viewModel.analyzeImageForLiveText( + index: index, + imageURL: imageURL, + recognitionLanguages: store.galleryDetail?.language.codes + ) + } + } + + private func handleImageFailure() { + store.send(.onWebImageFailed(index)) + } + + private func handleReloadTap() { + if case .failed(let error) = loadingState { + if case .webImageFailed = error { + store.send(.onWebImageRetry(index)) + } else { + store.send(.refetchImageURLs(index)) + } + } + } + + private func handleRefetch() { + store.send(.refetchImageURLs(index)) + } + + private func handleCopyImage(_ url: URL) { + store.send(.copyImage(url)) + } + + private func handleSaveImage(_ url: URL) { + store.send(.saveImage(url)) + } + + private func handleShareImage(_ url: URL) { + store.send(.shareImage(url)) + } +} + +// MARK: - Preview +struct ImageStackView_Previews: PreviewProvider { + static var previews: some View { + ImageStackView( + index: 1, + store: .init( + initialState: .init(gallery: .empty), + reducer: ReadingReducer.init + ), + setting: .constant(.init()), + viewModel: ReadingViewModel(), + gestureCoordinator: GestureCoordinator() + ) + .previewLayout(.sizeThatFits) + .padding() + } +} \ No newline at end of file diff --git a/EhPanda/View/Reading/Support/LiveTextHandler.swift b/EhPanda/View/Reading/Support/LiveTextHandler.swift deleted file mode 100644 index 9a9451f4..00000000 --- a/EhPanda/View/Reading/Support/LiveTextHandler.swift +++ /dev/null @@ -1,191 +0,0 @@ -// -// LiveTextHandler.swift -// EhPanda -// -// Created by xioxin on 2022/2/12. -// -// swiftlint:disable line_length -// Refercence -// https://www.codeproject.com/Articles/15573/2D-Polygon-Collision-Detection -// https://developer.apple.com/documentation/vision/recognizing_text_in_images -// https://github.com/TelegramMessenger/Telegram-iOS/blob/2a32c871882c4e1b1ccdecd34fccd301723b30d9/submodules/Translate/Sources/Translate.swift -// https://github.com/TelegramMessenger/Telegram-iOS/blob/0be460b147321b7455247aedca81ca819702959d/submodules/ImageContentAnalysis/Sources/ImageContentAnalysis.swift -// swiftlint:enable line_length -// - -import Vision -import SwiftUI -import Foundation - -final class LiveTextHandler: ObservableObject { - @Published var enablesLiveText = false - @Published var liveTextGroups = [Int: [LiveTextGroup]]() - @Published private(set) var focusedLiveTextGroup: LiveTextGroup? - - private var processingRequests = [VNRequest]() - - deinit { - cancelRequests() - } - - func cancelRequests() { - Logger.info("cancelRequests", context: [ - "processingRequestsCount": processingRequests.count - ]) - processingRequests.forEach { request in - request.cancel() - } - } - - func setFocusedLiveTextGroup(_ group: LiveTextGroup) { - Logger.info("setFocusedLiveTextGroup", context: ["group": group]) - focusedLiveTextGroup = group - } - - func analyzeImage(_ cgImage: CGImage, size: CGSize, index: Int, recognitionLanguages: [String]?) { - Logger.info("analyzeImage", context: [ - "index": index, "recognitionLanguages": recognitionLanguages as Any - ]) - - let requestHandler = VNImageRequestHandler(cgImage: cgImage) - let textRecognitionRequest = VNRecognizeTextRequest { [weak self] in - self?.textRecognitionHandler(request: $0, error: $1, size: size, index: index) - } - textRecognitionRequest.usesLanguageCorrection = true - textRecognitionRequest.preferBackgroundProcessing = true - if let languages = recognitionLanguages { - textRecognitionRequest.recognitionLanguages = languages - } - - processingRequests.append(textRecognitionRequest) - DispatchQueue.global(qos: .utility).async { [weak self] in - guard let self = self else { return } - do { - try requestHandler.perform([textRecognitionRequest]) - } catch { - self.removeRequest(textRecognitionRequest) - Logger.info("Unable to perform the requests.", context: ["error": error]) - } - } - } - - private func removeRequest(_ request: VNRequest) { - if let index = processingRequests.firstIndex(of: request) { - processingRequests.remove(at: index) - } - } - - private func textRecognitionHandler(request: VNRequest, error: Error?, size: CGSize, index: Int) { - Logger.info("textRecognitionHandler", context: [ - "request": request, "error": error as Any, "index": index - ]) - removeRequest(request) - - guard let observations = request.results as? [VNRecognizedTextObservation] else { return } - - DispatchQueue.global(qos: .userInteractive).async { [weak self] in - guard let self = self else { return } - let blocks: [LiveTextBlock] = observations.compactMap { observation in - guard let recognizedText = observation.topCandidates(1).first?.string else { return nil } - return .init( - text: recognizedText, - bounds: .init( - topLeft: observation.topLeft.verticalReversed, - topRight: observation.topRight.verticalReversed, - bottomLeft: observation.bottomLeft.verticalReversed, - bottomRight: observation.bottomRight.verticalReversed - ) - ) - } - - var groupData = [[LiveTextBlock]]() - blocks.forEach { newItem in - if let groupIndex = groupData.firstIndex(where: { items in - items.first { item in - let angle = abs(item.bounds.getAngle(size) - newItem.bounds.getAngle(size)) - .truncatingRemainder(dividingBy: 360.0) - let isAngleValid = angle < 5 || angle > (360 - 5) - let aHeight = item.bounds.getHeight(size) - let bHeight = newItem.bounds.getHeight(size) - let isHeightValid = abs(aHeight - bHeight) < (min(aHeight, bHeight) / 2) - - guard isAngleValid && isHeightValid else { return false } - return self.polygonsIntersecting( - lhs: item.bounds.expandingHalfHeight(size).edges, - rhs: newItem.bounds.expandingHalfHeight(size).edges - ) - } != nil - }) { - groupData[groupIndex].append(newItem) - } else { - groupData.append([newItem]) - } - } - - let groups = groupData.compactMap(LiveTextGroup.init) - DispatchQueue.main.async { - self.liveTextGroups[index] = groups - } - } - } - - private func polygonsIntersecting(lhs: [CGPoint], rhs: [CGPoint]) -> Bool { - guard !lhs.isEmpty, !rhs.isEmpty, lhs.count == rhs.count else { return false } - for points in [lhs, rhs] { - for index1 in 0..() + + // MARK: - Configuration + private var pageConfig: PageConfiguration = .init() + + // MARK: - Initialization + init() { + setupObservers() + } + + deinit { + cleanup() + } + + // MARK: - Setup Methods + func setup(pageCount: Int, setting: Setting) { + self.pageCount = pageCount + self.setting = setting + self.pageConfig = PageConfiguration(setting: setting) + + Logger.info("Page coordinator setup", context: [ + "pageCount": pageCount, + "readingDirection": setting.readingDirection.rawValue + ]) + } + + func setup(pageCount: Int, setting: Setting, initialPage: Int) { + setup(pageCount: pageCount, setting: setting) + + // Initialize slider value with reading progress + let validProgress = max(1, min(initialPage, pageCount)) + sliderValue = Float(validProgress) + + Logger.info("Page coordinator setup with initial page", context: [ + "initialPage": initialPage, + "validProgress": validProgress + ]) + } + + private func setupObservers() { + // Observe slider value changes for page navigation + $sliderValue + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) + .sink { [weak self] newValue in + self?.handleSliderValueChange(newValue) + } + .store(in: &cancellables) + } + + func cleanup() { + cancellables.removeAll() + } + + // MARK: - Page Mapping Methods + + /// Maps from pager index to page number + func mapFromPager( + index: Int, + pageCount: Int, + setting: Setting, + isLandscape: Bool = DeviceUtil.isLandscape + ) -> Int { + Logger.info("Map from pager", context: [ + "index": index, + "pageCount": pageCount, + "isDualPage": isDualPageMode(setting: setting, isLandscape: isLandscape) + ]) + + guard isDualPageMode(setting: setting, isLandscape: isLandscape) else { + return index + 1 + } + + guard index > 0 else { return 1 } + + let result = setting.exceptCover ? index * 2 : index * 2 + 1 + + // Handle edge case for last page in dual mode + if result + 1 == pageCount { + return pageCount + } else { + return result + } + } + + /// Maps from page number to pager index + func mapToPager( + index: Int, + setting: Setting, + isLandscape: Bool = DeviceUtil.isLandscape + ) -> Int { + Logger.info("Map to pager", context: [ + "index": index, + "isDualPage": isDualPageMode(setting: setting, isLandscape: isLandscape) + ]) + + guard isDualPageMode(setting: setting, isLandscape: isLandscape) else { + return index - 1 + } + + guard index > 1 else { return 0 } + + return setting.exceptCover ? index / 2 : (index - 1) / 2 + } + + // MARK: - Page Navigation + + /// Updates the current page and synchronizes slider + func updateCurrentPage(_ pageIndex: Int) { + let clampedIndex = max(1, min(pageIndex, pageCount)) + sliderValue = Float(clampedIndex) + + Logger.info("Updated current page", context: [ + "pageIndex": pageIndex, + "clampedIndex": clampedIndex + ]) + } + + /// Handles page navigation with bounds checking + func navigatePage(offset: Int, currentIndex: Int) -> Int { + let newIndex = currentIndex + offset + let clampedIndex = max(0, min(newIndex, pageCount - 1)) + + Logger.info("Navigate page", context: [ + "offset": offset, + "currentIndex": currentIndex, + "newIndex": newIndex, + "clampedIndex": clampedIndex + ]) + + return clampedIndex + } + + /// Gets valid page range for the current configuration + func getValidPageRange() -> ClosedRange { + return 1...pageCount + } + + /// Checks if a page index is valid + func isValidPageIndex(_ index: Int) -> Bool { + return index >= 1 && index <= pageCount + } + + // MARK: - Dual Page Support + + /// Determines if dual page mode should be active + func isDualPageMode(setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> Bool { + return isLandscape && + setting.enablesDualPageMode && + setting.readingDirection != .vertical + } + + /// Gets the page configuration for dual page mode + func getDualPageConfiguration( + for index: Int, + setting: Setting, + isLandscape: Bool = DeviceUtil.isLandscape + ) -> DualPageConfiguration { + let isDualPage = isDualPageMode(setting: setting, isLandscape: isLandscape) + let isReversed = setting.readingDirection == .rightToLeft + let isFirstSingle = setting.exceptCover + let isFirstPageAndSingle = index == 1 && isFirstSingle + + let firstIndex = isDualPage && isReversed && !isFirstPageAndSingle ? index + 1 : index + let secondIndex = firstIndex + (isReversed ? -1 : 1) + + let isValidFirstRange = firstIndex >= 1 && firstIndex <= pageCount + let isValidSecondRange = isFirstSingle + ? secondIndex >= 2 && secondIndex <= pageCount + : secondIndex >= 1 && secondIndex <= pageCount + + return DualPageConfiguration( + firstIndex: firstIndex, + secondIndex: secondIndex, + isFirstAvailable: isValidFirstRange, + isSecondAvailable: !isFirstPageAndSingle && isValidSecondRange && isDualPage, + isDualPage: isDualPage + ) + } + + // MARK: - Auto Play Support + + /// Gets the next page index for auto play + func getNextAutoPlayIndex(currentIndex: Int) -> Int? { + let nextIndex = currentIndex + 1 + guard nextIndex < pageCount else { return nil } + return nextIndex + } + + // MARK: - Private Methods + + private func handleSliderValueChange(_ newValue: Float) { + Logger.info("Handle slider value change", context: [ + "newValue": newValue, + "pageCount": pageCount + ]) + + // Validate slider value + let clampedValue = max(1, min(newValue, Float(pageCount))) + if clampedValue != newValue { + DispatchQueue.main.async { [weak self] in + self?.sliderValue = clampedValue + } + } + } +} + +// MARK: - Supporting Types + +/// Configuration for dual page display +struct DualPageConfiguration { + let firstIndex: Int + let secondIndex: Int + let isFirstAvailable: Bool + let isSecondAvailable: Bool + let isDualPage: Bool +} + +/// Configuration for page behavior +private struct PageConfiguration { + let enablesDualPage: Bool + let exceptCover: Bool + let readingDirection: ReadingDirection + + init(setting: Setting? = nil) { + self.enablesDualPage = setting?.enablesDualPageMode ?? false + self.exceptCover = setting?.exceptCover ?? false + self.readingDirection = setting?.readingDirection ?? .leftToRight + } +} + +// MARK: - Page Coordinator Extensions + +extension PageCoordinator { + /// Gets container data source for the current page configuration + func getContainerDataSource( + pageCount: Int, + setting: Setting, + isLandscape: Bool = DeviceUtil.isLandscape + ) -> [Int] { + let defaultData = Array(1...pageCount) + + guard isDualPageMode(setting: setting, isLandscape: isLandscape) else { + return defaultData + } + + let data = setting.exceptCover + ? [1] + Array(stride(from: 2, through: pageCount, by: 2)) + : Array(stride(from: 1, through: pageCount, by: 2)) + + Logger.info("Generated container data source", context: [ + "defaultCount": defaultData.count, + "dualPageCount": data.count, + "exceptCover": setting.exceptCover + ]) + + return data + } +} + +// MARK: - Image Stack Configuration + +/// Configuration for image stack display +struct ImageStackConfig { + let firstIndex: Int + let secondIndex: Int + let isFirstAvailable: Bool + let isSecondAvailable: Bool + + init(from dualPageConfig: DualPageConfiguration) { + self.firstIndex = dualPageConfig.firstIndex + self.secondIndex = dualPageConfig.secondIndex + self.isFirstAvailable = dualPageConfig.isFirstAvailable + self.isSecondAvailable = dualPageConfig.isSecondAvailable + } +} \ No newline at end of file diff --git a/EhPanda/View/Reading/Support/PageHandler.swift b/EhPanda/View/Reading/Support/PageHandler.swift deleted file mode 100644 index 1b03eea6..00000000 --- a/EhPanda/View/Reading/Support/PageHandler.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// PageHandler.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/02/09. -// - -import SwiftUI - -final class PageHandler: ObservableObject { - @Published var sliderValue: Float = 1 { - didSet { - Logger.info("sliderValue.didSet", context: ["sliderValue": sliderValue]) - } - } - - func mapFromPager(index: Int, pageCount: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> Int { - guard isLandscape && setting.enablesDualPageMode - && setting.readingDirection != .vertical - else { return index + 1 } - guard index > 0 else { return 1 } - - let result = setting.exceptCover ? index * 2 : index * 2 + 1 - - if result + 1 == pageCount { - return pageCount - } else { - return result - } - } - - func mapToPager(index: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> Int { - guard isLandscape && setting.enablesDualPageMode - && setting.readingDirection != .vertical - else { return index - 1 } - guard index > 1 else { return 0 } - - return setting.exceptCover ? index / 2 : (index - 1) / 2 - } -} diff --git a/EhPanda/View/Reading/Support/ReadingViewExtensions.swift b/EhPanda/View/Reading/Support/ReadingViewExtensions.swift new file mode 100644 index 00000000..6c0adcbf --- /dev/null +++ b/EhPanda/View/Reading/Support/ReadingViewExtensions.swift @@ -0,0 +1,445 @@ +// +// ReadingViewExtensions.swift +// EhPanda +// +// Created by zackie on 2025-07-28 for improved Reading view architecture +// + +import SwiftUI +import SwiftUIPager +import ComposableArchitecture + +// MARK: - Auto Play Policy +enum AutoPlayPolicy: Int, CaseIterable, Identifiable { + var id: Int { rawValue } + + case off = -1 + case sec1 = 1 + case sec2 = 2 + case sec3 = 3 + case sec4 = 4 + case sec5 = 5 +} + +extension AutoPlayPolicy { + /// Human-readable value for the auto play policy + var value: String { + switch self { + case .off: + return L10n.Localizable.Enum.AutoPlayPolicy.Value.off + default: + return L10n.Localizable.Common.Value.seconds("\(rawValue)") + } + } + + /// Time interval for the timer (0 means disabled) + var timeInterval: TimeInterval { + return rawValue > 0 ? TimeInterval(rawValue) : 0 + } + + /// Whether auto play is enabled + var isEnabled: Bool { + return self != .off + } +} + +// MARK: - Reading View Modifiers + +extension View { + /// Applies all reading view modifiers including sheets, progress HUD, and animations + func readingViewModifiers( + store: StoreOf, + setting: Binding, + blurRadius: Double + ) -> some View { + self + .readingSheets(store: store, setting: setting, blurRadius: blurRadius) + .readingProgressHUD(store: store) + .readingAnimations() + .readingStatusBar(store: store) + } + + /// Applies reading-specific sheet presentations + private func readingSheets( + store: StoreOf, + setting: Binding, + blurRadius: Double + ) -> some View { + self + .sheet(item: Binding( + get: { store.route?.readingSetting }, + set: { _ in store.send(.setNavigation(nil)) } + )) { _ in + NavigationView { + ReadingSettingView( + readingDirection: setting.readingDirection, + prefetchLimit: setting.prefetchLimit, + enablesLandscape: setting.enablesLandscape, + contentDividerHeight: setting.contentDividerHeight, + maximumScaleFactor: setting.maximumScaleFactor, + doubleTapScaleFactor: setting.doubleTapScaleFactor + ) + .readingSettingToolbar { + store.send(.setNavigation(nil)) + } + } + .accentColor(setting.wrappedValue.accentColor) + .tint(setting.wrappedValue.accentColor) + .autoBlur(radius: blurRadius) + .navigationViewStyle(.stack) + } + .sheet(item: Binding( + get: { store.route?.share }, + set: { _ in store.send(.setNavigation(nil)) } + )) { shareItemBox in + ActivityView(activityItems: [shareItemBox.wrappedValue.associatedValue]) + .accentColor(setting.wrappedValue.accentColor) + .autoBlur(radius: blurRadius) + } + } + + /// Applies progress HUD for reading operations + private func readingProgressHUD(store: StoreOf) -> some View { + self.progressHUD( + config: store.hudConfig, + unwrapping: Binding( + get: { store.route }, + set: { store.send(.setNavigation($0)) } + ), + case: \.hud + ) + } + + /// Applies reading-specific animations + private func readingAnimations() -> some View { + self + .animation(.linear(duration: 0.1), value: UUID()) // Placeholder for gesture animations + .animation(.default, value: UUID()) // Placeholder for other animations + } + + /// Configures status bar visibility + private func readingStatusBar(store: StoreOf) -> some View { + self.statusBar(hidden: !store.showsPanel) + } +} + +// MARK: - Reading Setting Toolbar + +extension View { + func readingSettingToolbar(dismissAction: @escaping () -> Void) -> some View { + self.toolbar { + CustomToolbarItem(placement: .cancellationAction) { + if !DeviceUtil.isPad && DeviceUtil.isLandscape { + Button(action: dismissAction) { + Image(systemSymbol: .chevronDown) + } + } + } + } + } +} + +// MARK: - Reading Changes Observer + +extension View { + /// Observes reading-related changes and handles side effects + func observeReadingChanges( + store: StoreOf, + setting: Binding, + viewModel: ReadingViewModel, + pageCoordinator: PageCoordinator, + page: Page + ) -> some View { + self + .onChange(of: page.index) { _, newValue in + handlePageIndexChange( + newValue: newValue, + store: store, + setting: setting.wrappedValue, + pageCoordinator: pageCoordinator + ) + } + .onChange(of: pageCoordinator.sliderValue) { _, newValue in + handleSliderValueChange( + newValue: newValue, + store: store, + showsSliderPreview: store.showsSliderPreview, + page: page, + pageCoordinator: pageCoordinator, + setting: setting.wrappedValue + ) + } + .onChange(of: store.showsSliderPreview) { _, newValue in + handleSliderPreviewChange( + newValue: newValue, + pageCoordinator: pageCoordinator, + viewModel: viewModel, + page: page, + setting: setting.wrappedValue + ) + } + .onChange(of: store.readingProgress) { _, newValue in + handleReadingProgressChange( + newValue: newValue, + pageCoordinator: pageCoordinator, + page: page, + setting: setting.wrappedValue + ) + } + .onChange(of: store.route) { _, newValue in + handleRouteChange(newValue: newValue, viewModel: viewModel) + } + .onChange(of: viewModel.enablesLiveText) { _, newValue in + handleLiveTextToggle( + newValue: newValue, + store: store, + viewModel: viewModel + ) + } + .onChange(of: store.webImageLoadSuccessIndices) { _, newValue in + handleImageLoadSuccess( + newValue: newValue, + viewModel: viewModel, + store: store + ) + } + .onChange(of: setting.wrappedValue.enablesLandscape) { _, newValue in + handleLandscapeSettingChange(newValue: newValue, store: store) + } + } + + private func handlePageIndexChange( + newValue: Int, + store: StoreOf, + setting: Setting, + pageCoordinator: PageCoordinator + ) { + Logger.info("Page index changed", context: ["pageIndex": newValue]) + + let mappedValue = pageCoordinator.mapFromPager( + index: newValue, + pageCount: store.gallery.pageCount, + setting: setting + ) + + pageCoordinator.sliderValue = Float(mappedValue) + + if store.databaseLoadingState == .idle { + store.send(.syncReadingProgress(mappedValue)) + } + } + + private func handleSliderValueChange( + newValue: Float, + store: StoreOf, + showsSliderPreview: Bool, + page: Page, + pageCoordinator: PageCoordinator, + setting: Setting + ) { + Logger.info("Slider value changed", context: ["sliderValue": newValue]) + + if !showsSliderPreview { + let pagerIndex = pageCoordinator.mapToPager(index: Int(newValue), setting: setting) + if page.index != pagerIndex { + page.update(.new(index: pagerIndex)) + Logger.info("Pager updated from slider", context: ["pagerIndex": pagerIndex]) + } + } + } + + private func handleSliderPreviewChange( + newValue: Bool, + pageCoordinator: PageCoordinator, + viewModel: ReadingViewModel, + page: Page, + setting: Setting + ) { + Logger.info("Slider preview changed", context: ["isShown": newValue]) + + if !newValue { + let pagerIndex = pageCoordinator.mapToPager( + index: Int(pageCoordinator.sliderValue), + setting: setting + ) + if page.index != pagerIndex { + page.update(.new(index: pagerIndex)) + } + } + + viewModel.stopAutoPlay() + } + + private func handleReadingProgressChange( + newValue: Int, + pageCoordinator: PageCoordinator, + page: Page, + setting: Setting + ) { + Logger.info("Reading progress changed", context: ["readingProgress": newValue]) + + // Ensure valid reading progress (at least page 1) + let validProgress = max(1, newValue) + + // Update slider value + pageCoordinator.sliderValue = Float(validProgress) + + // Update pager position to match the reading progress + let pagerIndex = pageCoordinator.mapToPager(index: validProgress, setting: setting) + if page.index != pagerIndex { + page.update(.new(index: pagerIndex)) + Logger.info("Pager updated from reading progress", context: [ + "readingProgress": validProgress, + "pagerIndex": pagerIndex + ]) + } + } + + private func handleRouteChange(newValue: ReadingReducer.Route?, viewModel: ReadingViewModel) { + Logger.info("Route changed", context: ["route": newValue as Any]) + + if let route = newValue, ![ReadingReducer.Route.hud, nil].contains(where: { $0 == route }) { + viewModel.stopAutoPlay() + } + } + + private func handleLiveTextToggle( + newValue: Bool, + store: StoreOf, + viewModel: ReadingViewModel + ) { + Logger.info("Live text toggled", context: ["isEnabled": newValue]) + + if newValue { + store.webImageLoadSuccessIndices.forEach { index in + viewModel.analyzeImageForLiveText( + index: index, + imageURL: store.imageURLs[index], + recognitionLanguages: store.galleryDetail?.language.codes + ) + } + } + } + + private func handleImageLoadSuccess( + newValue: Set, + viewModel: ReadingViewModel, + store: StoreOf + ) { + Logger.info("Image load success indices changed", context: [ + "count": newValue.count + ]) + + if viewModel.enablesLiveText { + newValue.forEach { index in + viewModel.analyzeImageForLiveText( + index: index, + imageURL: store.imageURLs[index], + recognitionLanguages: store.galleryDetail?.language.codes + ) + } + } + } + + private func handleLandscapeSettingChange(newValue: Bool, store: StoreOf) { + Logger.info("Landscape setting changed", context: ["newValue": newValue]) + store.send(.setOrientationPortrait(!newValue)) + } +} + +// MARK: - Reading Control Panel + +/// Replacement for the original ControlPanel component with improved architecture +struct ReadingControlPanel: View { + @Binding private var showsPanel: Bool + @Binding private var showsSliderPreview: Bool + @Binding private var sliderValue: Float + @Binding private var setting: Setting + @Binding private var enablesLiveText: Bool + @Binding private var autoPlayPolicy: AutoPlayPolicy + + private let range: ClosedRange + private let previewURLs: [Int: URL] + private let dismissGesture: G + private let dismissAction: () -> Void + private let navigateSettingAction: () -> Void + private let reloadAllImagesAction: () -> Void + private let retryAllFailedImagesAction: () -> Void + private let fetchPreviewURLsAction: (Int) -> Void + + init( + showsPanel: Binding, + showsSliderPreview: Binding, + sliderValue: Binding, + setting: Binding, + enablesLiveText: Binding, + autoPlayPolicy: Binding, + range: ClosedRange, + previewURLs: [Int: URL], + dismissGesture: G, + dismissAction: @escaping () -> Void, + navigateSettingAction: @escaping () -> Void, + reloadAllImagesAction: @escaping () -> Void, + retryAllFailedImagesAction: @escaping () -> Void, + fetchPreviewURLsAction: @escaping (Int) -> Void + ) { + _showsPanel = showsPanel + _showsSliderPreview = showsSliderPreview + _sliderValue = sliderValue + _setting = setting + _enablesLiveText = enablesLiveText + _autoPlayPolicy = autoPlayPolicy + self.range = range + self.previewURLs = previewURLs + self.dismissGesture = dismissGesture + self.dismissAction = dismissAction + self.navigateSettingAction = navigateSettingAction + self.reloadAllImagesAction = reloadAllImagesAction + self.retryAllFailedImagesAction = retryAllFailedImagesAction + self.fetchPreviewURLsAction = fetchPreviewURLsAction + } + + var body: some View { + ControlPanel( + showsPanel: $showsPanel, + showsSliderPreview: $showsSliderPreview, + sliderValue: $sliderValue, + setting: $setting, + enablesLiveText: $enablesLiveText, + autoPlayPolicy: $autoPlayPolicy, + range: range, + previewURLs: previewURLs, + dismissGesture: dismissGesture, + dismissAction: dismissAction, + navigateSettingAction: navigateSettingAction, + reloadAllImagesAction: reloadAllImagesAction, + retryAllFailedImagesAction: retryAllFailedImagesAction, + fetchPreviewURLsAction: fetchPreviewURLsAction + ) + } +} + +// MARK: - Route Binding Extensions + +extension ReadingReducer.Route { + var readingSetting: EquatableVoid? { + if case .readingSetting(let void) = self { + return void + } + return nil + } + + var share: IdentifiableBox? { + if case .share(let shareItem) = self { + return shareItem + } + return nil + } + + var hud: Void? { + if case .hud = self { + return () + } + return nil + } +} \ No newline at end of file diff --git a/EhPanda/View/Reading/Support/ReadingViewModel.swift b/EhPanda/View/Reading/Support/ReadingViewModel.swift new file mode 100644 index 00000000..fdfc5904 --- /dev/null +++ b/EhPanda/View/Reading/Support/ReadingViewModel.swift @@ -0,0 +1,321 @@ +// +// ReadingViewModel.swift +// EhPanda +// +// Created by zackie on 2025-07-28 for improved Reading view architecture +// + +import SwiftUI +import Combine +import Kingfisher + +// MARK: - Reading View Model +final class ReadingViewModel: ObservableObject { + // MARK: - Published Properties + @Published var enablesLiveText = false + @Published var liveTextGroups = [Int: [LiveTextGroup]]() + @Published var focusedLiveTextGroup: LiveTextGroup? + @Published var autoPlayPolicy: AutoPlayPolicy = .off + @Published var webImageLoadSuccessIndices = Set() + + // MARK: - Private Properties + private var autoPlayTimer: Timer? + private var liveTextRequests = [VNRequest]() + private var cancellables = Set() + + // MARK: - Initialization + init() { + setupObservers() + } + + deinit { + cleanup() + } + + // MARK: - Setup Methods + func setup(with state: ReadingReducer.State, setting: Setting) { + // Initialize with current state + webImageLoadSuccessIndices = state.webImageLoadSuccessIndices + + // Setup live text if needed + if enablesLiveText { + analyzeExistingImages(indices: Array(webImageLoadSuccessIndices)) + } + } + + private func setupObservers() { + // Observe live text state changes + $enablesLiveText + .sink { [weak self] isEnabled in + if isEnabled { + self?.analyzeExistingImages(indices: Array(self?.webImageLoadSuccessIndices ?? [])) + } else { + self?.clearLiveText() + } + } + .store(in: &cancellables) + } + + // MARK: - Auto Play Management + func setAutoPlayPolicy(_ policy: AutoPlayPolicy, pageUpdater: @escaping () -> Void) { + Logger.info("Setting auto play policy", context: ["policy": policy]) + + autoPlayPolicy = policy + autoPlayTimer?.invalidate() + + if policy.isEnabled { + autoPlayTimer = Timer.scheduledTimer(withTimeInterval: policy.timeInterval, repeats: true) { _ in + pageUpdater() + } + } + } + + func stopAutoPlay() { + autoPlayTimer?.invalidate() + autoPlayPolicy = .off + } + + // MARK: - Live Text Management + func setFocusedLiveTextGroup(_ group: LiveTextGroup) { + Logger.info("Setting focused live text group", context: ["group": group]) + focusedLiveTextGroup = group + } + + func analyzeImageForLiveText( + index: Int, + imageURL: URL?, + recognitionLanguages: [String]? + ) { + Logger.info("Analyzing image for live text", context: ["index": index]) + + guard enablesLiveText, + liveTextGroups[index] == nil, + let imageURL = imageURL, + let key = imageURL.absoluteString as String? + else { + Logger.info("Skipping live text analysis", context: [ + "enablesLiveText": enablesLiveText, + "alreadyAnalyzed": liveTextGroups[index] != nil, + "hasURL": imageURL != nil + ]) + return + } + + KingfisherManager.shared.cache.retrieveImage(forKey: key) { [weak self] result in + switch result { + case .success(let result): + if let image = result.image, let cgImage = image.cgImage { + self?.performLiveTextAnalysis( + cgImage: cgImage, + size: image.size, + index: index, + recognitionLanguages: recognitionLanguages + ) + } else { + Logger.info("Live text analysis: image not found", context: ["index": index]) + } + case .failure(let error): + Logger.info("Live text analysis failed", context: [ + "index": index, + "error": error + ] as [String: Any]) + } + } + } + + private func analyzeExistingImages(indices: [Int]) { + indices.forEach { index in + // This would be called with proper parameters from the main view + // analyzeImageForLiveText(index: index, imageURL: nil, recognitionLanguages: nil) + } + } + + private func performLiveTextAnalysis( + cgImage: CGImage, + size: CGSize, + index: Int, + recognitionLanguages: [String]? + ) { + let requestHandler = VNImageRequestHandler(cgImage: cgImage) + let textRecognitionRequest = VNRecognizeTextRequest { [weak self] request, error in + self?.handleLiveTextRecognition( + request: request, + error: error, + size: size, + index: index + ) + } + + textRecognitionRequest.usesLanguageCorrection = true + textRecognitionRequest.preferBackgroundProcessing = true + + if let languages = recognitionLanguages { + textRecognitionRequest.recognitionLanguages = languages + } + + liveTextRequests.append(textRecognitionRequest) + + DispatchQueue.global(qos: .utility).async { [weak self] in + do { + try requestHandler.perform([textRecognitionRequest]) + } catch { + self?.removeLiveTextRequest(textRecognitionRequest) + Logger.info("Live text recognition failed", context: ["error": error]) + } + } + } + + private func handleLiveTextRecognition( + request: VNRequest, + error: Error?, + size: CGSize, + index: Int + ) { + removeLiveTextRequest(request) + + guard let observations = request.results as? [VNRecognizedTextObservation] else { + return + } + + DispatchQueue.global(qos: .userInteractive).async { [weak self] in + let blocks = self?.processLiveTextObservations(observations) ?? [] + let groups = self?.groupLiveTextBlocks(blocks, size: size) ?? [] + + DispatchQueue.main.async { + self?.liveTextGroups[index] = groups + } + } + } + + private func processLiveTextObservations(_ observations: [VNRecognizedTextObservation]) -> [LiveTextBlock] { + return observations.compactMap { observation in + guard let recognizedText = observation.topCandidates(1).first?.string else { + return nil + } + + return LiveTextBlock( + text: recognizedText, + bounds: LiveTextBounds( + topLeft: observation.topLeft.verticalReversed, + topRight: observation.topRight.verticalReversed, + bottomLeft: observation.bottomLeft.verticalReversed, + bottomRight: observation.bottomRight.verticalReversed + ) + ) + } + } + + private func groupLiveTextBlocks(_ blocks: [LiveTextBlock], size: CGSize) -> [LiveTextGroup] { + var groupData = [[LiveTextBlock]]() + + blocks.forEach { newBlock in + if let groupIndex = findMatchingGroup(for: newBlock, in: groupData, size: size) { + groupData[groupIndex].append(newBlock) + } else { + groupData.append([newBlock]) + } + } + + return groupData.compactMap(LiveTextGroup.init) + } + + private func findMatchingGroup( + for newBlock: LiveTextBlock, + in groupData: [[LiveTextBlock]], + size: CGSize + ) -> Int? { + return groupData.firstIndex { blocks in + blocks.contains { existingBlock in + areLiveTextBlocksCompatible(existingBlock, newBlock, size: size) + } + } + } + + private func areLiveTextBlocksCompatible( + _ block1: LiveTextBlock, + _ block2: LiveTextBlock, + size: CGSize + ) -> Bool { + let angle1 = block1.bounds.getAngle(size) + let angle2 = block2.bounds.getAngle(size) + let angleDiff = abs(angle1 - angle2).truncatingRemainder(dividingBy: 360.0) + let isAngleValid = angleDiff < 5 || angleDiff > (360 - 5) + + let height1 = block1.bounds.getHeight(size) + let height2 = block2.bounds.getHeight(size) + let isHeightValid = abs(height1 - height2) < (min(height1, height2) / 2) + + guard isAngleValid && isHeightValid else { return false } + + return arePolygonsIntersecting( + lhs: block1.bounds.expandingHalfHeight(size).edges, + rhs: block2.bounds.expandingHalfHeight(size).edges + ) + } + + private func arePolygonsIntersecting(lhs: [CGPoint], rhs: [CGPoint]) -> Bool { + guard !lhs.isEmpty, !rhs.isEmpty, lhs.count == rhs.count else { return false } + + for points in [lhs, rhs] { + for index1 in 0.. (min: Double, max: Double) { + let projections = points.map { point in + basis.x * point.x + basis.y * point.y + } + return (projections.min() ?? 0, projections.max() ?? 0) + } + + private func clearLiveText() { + liveTextGroups.removeAll() + focusedLiveTextGroup = nil + cancelLiveTextRequests() + } + + private func removeLiveTextRequest(_ request: VNRequest) { + if let index = liveTextRequests.firstIndex(of: request) { + liveTextRequests.remove(at: index) + } + } + + private func cancelLiveTextRequests() { + Logger.info("Canceling live text requests", context: [ + "count": liveTextRequests.count + ]) + liveTextRequests.forEach { $0.cancel() } + liveTextRequests.removeAll() + } + + // MARK: - Cleanup + func cleanup() { + autoPlayTimer?.invalidate() + cancelLiveTextRequests() + cancellables.removeAll() + } +} + +// MARK: - Extensions +private extension CGPoint { + var verticalReversed: CGPoint { + CGPoint(x: x, y: 1 - y) + } +} + +// MARK: - Import Vision Framework +import Vision \ No newline at end of file diff --git a/ShareExtension/Info.plist b/ShareExtension/Info.plist index 6c33430d..4c8c1c00 100644 --- a/ShareExtension/Info.plist +++ b/ShareExtension/Info.plist @@ -2,6 +2,8 @@ + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) NSExtension NSExtensionAttributes @@ -17,7 +19,5 @@ NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).ShareViewController - CFBundleShortVersionString - 2.7.10 From 8076540183b73a9d07a428a311047275ad60c05a Mon Sep 17 00:00:00 2001 From: Zack Yu <59857887+aalberrty@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:05:56 +0800 Subject: [PATCH 02/40] Update deploy.yml --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c95fb1db..a241cdc5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,7 +6,7 @@ on: types: [closed] env: DEVELOPER_DIR: /Applications/Xcode_16.2.app - APP_VERSION: '2.7.10' + APP_VERSION: '2.8' SCHEME_NAME: 'EhPanda' ALTSTORE_JSON_PATH: './AltStore.json' BUILDS_PATH: '/tmp/action-builds' @@ -20,7 +20,7 @@ env: jobs: Deploy: runs-on: macos-15 - if: github.event.pull_request.merged == true && github.event.pull_request.user.login == 'chihchy' + if: github.event.pull_request.merged == true && github.event.pull_request.user.login == 'aalberrty' steps: - name: Checkout uses: actions/checkout@v4 From fe49d28332b2888494c290f39e22fe879d9f8e08 Mon Sep 17 00:00:00 2001 From: Zack Yu <59857887+aalberrty@users.noreply.github.com> Date: Mon, 28 Jul 2025 21:36:16 +0800 Subject: [PATCH 03/40] Update README.md --- README.md | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5bf4dae0..0c814f31 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@

EhPanda

-

An unofficial E-Hentai App for iOS.

+

An unofficial fork of the E-Hentai App for iOS.

- +

@@ -25,7 +25,7 @@ GitHub Readme: [README.{lang}.md](/READMEs) https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/blob/main/src/main.js) ## Installation -1. Get the ipa file from [Releases](https://github.com/EhPanda-Team/EhPanda/releases). +1. Get the ipa file from [Releases](https://github.com/aalberrty/EhPanda/releases). 2. Use some software like [AltStore](https://altstore.io) to install the ipa file on your device. ## System Requirements @@ -37,12 +37,4 @@ The content in this application is derived from E-Hentai, which is user-generate **Users of this application should access the E-Hentai content at their own risk.** ## Questions & Feedback -[![Twitter](https://img.shields.io/badge/Twitter-2CA5E0?style=for-the-badge&logo=twitter&logoColor=white)](https://twitter.com/ehpandaapp) -[![Discord](https://img.shields.io/badge/Discord-7289DA?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/BSBE9FCBTq) -[![Telegram](https://img.shields.io/badge/Telegram-858585?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/ehpanda) - -## Screenshots -https://ehpanda.app - -## App Icon -Copyright © 2024 荒木辰造. All rights reserved. +Please use [Github Issues](https://github.com/aalberrty/EhPanda/issues) for feedback. From 80e14c3c635bed3a33f1fe8b820b62c53205e0af Mon Sep 17 00:00:00 2001 From: Zack Yu <59857887+aalberrty@users.noreply.github.com> Date: Mon, 28 Jul 2025 21:37:06 +0800 Subject: [PATCH 04/40] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0c814f31..db17ad1a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

An unofficial fork of the E-Hentai App for iOS.

- +

From 0c67f0c25b44e29815defef49efae522a06005a9 Mon Sep 17 00:00:00 2001 From: Zack Yu <59857887+aalberrty@users.noreply.github.com> Date: Mon, 28 Jul 2025 21:37:47 +0800 Subject: [PATCH 05/40] Update README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index db17ad1a..65cc9bfc 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,6 @@ App Strings: [{lang}.lproj](/EhPanda/App) GitHub Readme: [README.{lang}.md](/READMEs) -https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/blob/main/src/main.js) - ## Installation 1. Get the ipa file from [Releases](https://github.com/aalberrty/EhPanda/releases). 2. Use some software like [AltStore](https://altstore.io) to install the ipa file on your device. From e9b6f42bb34ac13d43bdf2391e9739b14a3477e3 Mon Sep 17 00:00:00 2001 From: ZackYu <59857887+aalberrty@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:23:25 +0800 Subject: [PATCH 06/40] fix zooming boundaries --- .../Reading/Support/GestureCoordinator.swift | 63 +++++++------------ 1 file changed, 21 insertions(+), 42 deletions(-) diff --git a/EhPanda/View/Reading/Support/GestureCoordinator.swift b/EhPanda/View/Reading/Support/GestureCoordinator.swift index 521c5603..b43377c6 100644 --- a/EhPanda/View/Reading/Support/GestureCoordinator.swift +++ b/EhPanda/View/Reading/Support/GestureCoordinator.swift @@ -121,6 +121,7 @@ final class GestureCoordinator: ObservableObject { } } else { scale = finalScale + // Apply constraints after scale change to ensure proper bounds constrainOffset() } @@ -154,14 +155,14 @@ final class GestureCoordinator: ObservableObject { height: baseOffset.height + currentPanOffset.height ) - // Temporarily remove constraints for testing - offset = totalOffset + // Apply boundary constraints to prevent dragging beyond image edges + offset = constrainOffset(totalOffset) Logger.info("Offset updated", context: [ "adjustedTranslation": adjustedTranslation, "currentPanOffset": currentPanOffset, "totalOffset": totalOffset, - "offset": offset + "constrainedOffset": offset ]) } @@ -175,8 +176,12 @@ final class GestureCoordinator: ObservableObject { guard scale > 1.0 else { return } Logger.info("Handle drag ended") - // Update base offset with final position - baseOffset = offset + // Ensure the final position is properly constrained + let finalOffset = constrainOffset(offset) + offset = finalOffset + + // Update base offset with final constrained position + baseOffset = finalOffset currentPanOffset = .zero } @@ -233,17 +238,18 @@ final class GestureCoordinator: ObservableObject { private func constrainOffset(_ newOffset: CGSize? = nil) -> CGSize { let targetOffset = newOffset ?? offset - let constrainedWidth = constrainOffsetDimension( - value: targetOffset.width, - anchor: scaleAnchor.x, - screenSize: DeviceUtil.absWindowW - ) + // Calculate the maximum allowed offset based on scale and screen size + let screenWidth = DeviceUtil.absWindowW + let screenHeight = DeviceUtil.absWindowH - let constrainedHeight = constrainOffsetDimension( - value: targetOffset.height, - anchor: scaleAnchor.y, - screenSize: DeviceUtil.absWindowH - ) + // When scaled, the image is larger than the screen, so we need to constrain + // the offset to keep the image content visible + let maxOffsetX = screenWidth * (scale - 1) / 2 + let maxOffsetY = screenHeight * (scale - 1) / 2 + + // Apply constraints to keep the image within bounds + let constrainedWidth = min(max(targetOffset.width, -maxOffsetX), maxOffsetX) + let constrainedHeight = min(max(targetOffset.height, -maxOffsetY), maxOffsetY) let constrained = CGSize(width: constrainedWidth, height: constrainedHeight) @@ -253,33 +259,6 @@ final class GestureCoordinator: ObservableObject { return constrained } - - private func constrainOffsetDimension( - value: Double, - anchor: Double, - screenSize: Double - ) -> Double { - let margin = screenSize * (scale - 1) / 2 - let leadingMargin = (anchor / 0.5) * margin - let trailingMargin = ((1 - anchor) / 0.5) * margin - - return min(max(value, -trailingMargin), leadingMargin) - } - - private func constrainOffsetSimple(_ newOffset: CGSize) -> CGSize { - let screenWidth = DeviceUtil.absWindowW - let screenHeight = DeviceUtil.absWindowH - - // Calculate maximum allowed offset based on zoom level with more flexibility - let maxOffsetX = screenWidth * (scale - 1) * 0.8 // Allow 80% of theoretical max - let maxOffsetY = screenHeight * (scale - 1) * 0.8 - - // Apply bounds with more flexibility for natural panning - let constrainedWidth = min(max(newOffset.width, -maxOffsetX), maxOffsetX) - let constrainedHeight = min(max(newOffset.height, -maxOffsetY), maxOffsetY) - - return CGSize(width: constrainedWidth, height: constrainedHeight) - } } // MARK: - Supporting Types From a559192ff0f9700f7aafb135c2787e593e21d93e Mon Sep 17 00:00:00 2001 From: ZackYu <59857887+aalberrty@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:31:02 +0800 Subject: [PATCH 07/40] Revert README.md to original state --- README.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 65cc9bfc..5bf4dae0 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@

EhPanda

-

An unofficial fork of the E-Hentai App for iOS.

+

An unofficial E-Hentai App for iOS.

- +

@@ -22,8 +22,10 @@ App Strings: [{lang}.lproj](/EhPanda/App) GitHub Readme: [README.{lang}.md](/READMEs) +https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/blob/main/src/main.js) + ## Installation -1. Get the ipa file from [Releases](https://github.com/aalberrty/EhPanda/releases). +1. Get the ipa file from [Releases](https://github.com/EhPanda-Team/EhPanda/releases). 2. Use some software like [AltStore](https://altstore.io) to install the ipa file on your device. ## System Requirements @@ -35,4 +37,12 @@ The content in this application is derived from E-Hentai, which is user-generate **Users of this application should access the E-Hentai content at their own risk.** ## Questions & Feedback -Please use [Github Issues](https://github.com/aalberrty/EhPanda/issues) for feedback. +[![Twitter](https://img.shields.io/badge/Twitter-2CA5E0?style=for-the-badge&logo=twitter&logoColor=white)](https://twitter.com/ehpandaapp) +[![Discord](https://img.shields.io/badge/Discord-7289DA?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/BSBE9FCBTq) +[![Telegram](https://img.shields.io/badge/Telegram-858585?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/ehpanda) + +## Screenshots +https://ehpanda.app + +## App Icon +Copyright © 2024 荒木辰造. All rights reserved. From 666d2dfd82cd82ffe91d9e4f0e6a7371708b4b93 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 29 Jul 2025 00:36:15 +0800 Subject: [PATCH 08/40] Fix CODEOWNERS --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 665c689d..449f199b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,7 +5,7 @@ # the repo. Unless a later match takes precedence, # @global-owner1 and @global-owner2 will be requested for # review when someone opens a pull request. -* @ehpanda-maintainers +# * @global-owner1 @global-owner2 # Order is important; the last matching pattern takes the most # precedence. When someone opens a pull request that only @@ -22,7 +22,7 @@ # be identified in the format @org/team-name. Teams must have # explicit write access to the repository. In this example, # the octocats team in the octo-org organization owns all .txt files. -# *.txt @octo-org/octocats +* @EhPanda-Team/ehpanda-maintainers # In this example, @doctocat owns any files in the build/logs # directory at the root of the repository and any of its From 56b82865325aa57d4d29eaaae644335c68af54e9 Mon Sep 17 00:00:00 2001 From: Zack Asahina <59857887+aalberrty@users.noreply.github.com> Date: Tue, 29 Jul 2025 00:40:16 +0800 Subject: [PATCH 09/40] bump version --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a241cdc5..5f72eb55 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,7 +6,7 @@ on: types: [closed] env: DEVELOPER_DIR: /Applications/Xcode_16.2.app - APP_VERSION: '2.8' + APP_VERSION: '2.8.0' SCHEME_NAME: 'EhPanda' ALTSTORE_JSON_PATH: './AltStore.json' BUILDS_PATH: '/tmp/action-builds' @@ -20,7 +20,7 @@ env: jobs: Deploy: runs-on: macos-15 - if: github.event.pull_request.merged == true && github.event.pull_request.user.login == 'aalberrty' + if: github.event.pull_request.merged == true && github.event.pull_request.user.login == 'chihchy' steps: - name: Checkout uses: actions/checkout@v4 From a9604f7f988f8df767145483d6d3619192ab7909 Mon Sep 17 00:00:00 2001 From: Zack Asahina <59857887+aalberrty@users.noreply.github.com> Date: Tue, 29 Jul 2025 00:41:28 +0800 Subject: [PATCH 10/40] revert bundle identifier --- EhPanda.xcodeproj/project.pbxproj | 40 +++++++++++++++---------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index 86fdb74e..f7212022 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -2092,10 +2092,10 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 156; - DEVELOPMENT_TEAM = RYCYM2Y5FL; + DEVELOPMENT_TEAM = 9SKQ7QTZ74; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2106,9 +2106,9 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda.shareExtension; + PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = ShareExtension_Dev; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -2120,10 +2120,10 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 156; - DEVELOPMENT_TEAM = RYCYM2Y5FL; + DEVELOPMENT_TEAM = 9SKQ7QTZ74; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; @@ -2134,9 +2134,9 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda.shareExtension; + PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda.shareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = ShareExtension_Dev; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -2271,11 +2271,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = RYCYM2Y5FL; + DEVELOPMENT_TEAM = 9SKQ7QTZ74; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -2284,9 +2284,9 @@ "@executable_path/Frameworks", ); OTHER_LDFLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda; + PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = App_Dev; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2300,11 +2300,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = EhPanda/EhPanda.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = RYCYM2Y5FL; + DEVELOPMENT_TEAM = 9SKQ7QTZ74; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -2313,9 +2313,9 @@ "@executable_path/Frameworks", ); OTHER_LDFLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = app.zack.ehpanda; + PRODUCT_BUNDLE_IDENTIFIER = app.ehpanda; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = App_Dev; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; From a04ec80fd1f97618f19f353527dc894d8f4fce5a Mon Sep 17 00:00:00 2001 From: ZackYu <59857887+aalberrty@users.noreply.github.com> Date: Tue, 29 Jul 2025 05:12:53 +0800 Subject: [PATCH 11/40] Update liquid glass to control panel in reading view. --- .../View/Reading/Support/ControlPanel.swift | 227 +++++++++++++++--- 1 file changed, 196 insertions(+), 31 deletions(-) diff --git a/EhPanda/View/Reading/Support/ControlPanel.swift b/EhPanda/View/Reading/Support/ControlPanel.swift index cc08a2d6..67f6ceb2 100644 --- a/EhPanda/View/Reading/Support/ControlPanel.swift +++ b/EhPanda/View/Reading/Support/ControlPanel.swift @@ -119,15 +119,129 @@ private struct UpperPanel: View { } var body: some View { - ZStack { - HStack { + HStack { + // Liquid Glass Dismiss Button + if #available(iOS 26.0, *) { Button(action: dismissAction) { Image(systemSymbol: .xmark) + .font(.title2) + .foregroundColor(.primary) + .frame(width: 44, height: 44) } - .font(.title2).padding(.leading, 20) - Spacer() - Slider(value: .constant(0)).opacity(0) - Spacer() + .glassEffect() + .padding(.leading, 20) + } else { + Button(action: dismissAction) { + Image(systemSymbol: .xmark) + .font(.title2) + .foregroundColor(.primary) + .frame(width: 44, height: 44) + .background(Material.ultraThinMaterial) + .clipShape(Circle()) + } + .padding(.leading, 20) + } + + Spacer() + + // Page Number Display in Liquid Glass Bubble + if #available(iOS 26.0, *) { + Text(title) + .bold() + .lineLimit(1) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .glassEffect() + } else { + Text(title) + .bold() + .lineLimit(1) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Material.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + Spacer() + + // Toolbar Grouped in Liquid Glass Container + if #available(iOS 26.0, *) { + HStack(spacing: 16) { + Button { + enablesLiveText.toggle() + } + label: { + Image(systemSymbol: .viewfinderCircle) + .symbolVariant(enablesLiveText ? .fill : .none) + .font(.title2) + } + + if DeviceUtil.isLandscape && setting.readingDirection != .vertical { + Menu { + Button { + setting.enablesDualPageMode.toggle() + } label: { + Text(L10n.Localizable.ReadingView.ToolbarItem.Title.dualPageMode) + if setting.enablesDualPageMode { + Image(systemSymbol: .checkmark) + } + } + Button { + setting.exceptCover.toggle() + } label: { + Text(L10n.Localizable.ReadingView.ToolbarItem.Title.exceptTheCover) + if setting.exceptCover { + Image(systemSymbol: .checkmark) + } + } + .disabled(!setting.enablesDualPageMode) + } label: { + Image(systemSymbol: .rectangleSplit2x1) + .symbolVariant(setting.enablesDualPageMode ? .fill : .none) + .font(.title2) + } + } + + Menu { + Text(L10n.Localizable.ReadingView.ToolbarItem.Title.autoPlay).foregroundColor(.secondary) + ForEach(AutoPlayPolicy.allCases) { policy in + Button { + autoPlayPolicy = policy + } label: { + Text(policy.value) + if autoPlayPolicy == policy { + Image(systemSymbol: .checkmark) + } + } + } + } label: { + Image(systemSymbol: .timer) + .font(.title2) + } + .menuStyle(BorderlessButtonMenuStyle()) + + ToolbarFeaturesMenu { + Button(action: retryAllFailedImagesAction) { + Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) + Text(L10n.Localizable.ReadingView.ToolbarItem.Button.retryAllFailedImages) + } + Button(action: reloadAllImagesAction) { + Image(systemSymbol: .arrowCounterclockwise) + Text(L10n.Localizable.ReadingView.ToolbarItem.Button.reloadAllImages) + } + Button(action: navigateSettingAction) { + Image(systemSymbol: .gear) + Text(L10n.Localizable.ReadingView.ToolbarItem.Button.readingSetting) + } + } + .font(.title2) + .menuStyle(BorderlessButtonMenuStyle()) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .glassEffect() + .padding(.trailing, 20) + } else { HStack(spacing: 20) { Button { enablesLiveText.toggle() @@ -191,10 +305,13 @@ private struct UpperPanel: View { .padding(.trailing, 20) } .font(.title2) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Material.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .padding(.trailing, 20) } - Text(title).bold().lineLimit(1).padding() } - .background(.thinMaterial) } } @@ -227,33 +344,81 @@ private struct LowerPanel: View { var body: some View { VStack(spacing: 30) { - Button(action: dismissAction) { - Image(systemSymbol: .xmark).foregroundColor(.primary).padding() - .background(.ultraThinMaterial).cornerRadius(.infinity) + // Dismiss Button + if #available(iOS 26.0, *) { + Button(action: dismissAction) { + Image(systemSymbol: .xmark) + .foregroundColor(.primary) + .font(.title2) + .frame(width: 44, height: 44) + } + .glassEffect(in: RoundedRectangle(cornerRadius: 22)) + .gesture(dismissGesture) + .opacity(showsSliderPreview ? 0 : 1) + } else { + Button(action: dismissAction) { + Image(systemSymbol: .xmark) + .foregroundColor(.primary) + .font(.title2) + .frame(width: 44, height: 44) + .background(Material.ultraThinMaterial) + .clipShape(Circle()) + } + .gesture(dismissGesture) + .opacity(showsSliderPreview ? 0 : 1) } - .gesture(dismissGesture).opacity(showsSliderPreview ? 0 : 1) - VStack(spacing: 0) { - SliderPreivew( - showsSliderPreview: $showsSliderPreview, - sliderValue: $sliderValue, previewURLs: previewURLs, range: range, - isReversed: isReversed, fetchPreviewURLsAction: fetchPreviewURLsAction - ) - VStack { - HStack { - Text(isReversed ? "\(Int(range.upperBound))" : "\(Int(range.lowerBound))") - .fontWeight(.medium).font(.caption).padding() - Slider( - value: $sliderValue, in: range, step: 1, - onEditingChanged: { showsSliderPreview = $0 } - ) - .rotationEffect(.init(degrees: isReversed ? 180 : 0)) - Text(isReversed ? "\(Int(range.lowerBound))" : "\(Int(range.upperBound))") - .fontWeight(.medium).font(.caption).padding() + + // Slider in Liquid Glass Bubble + if #available(iOS 26.0, *) { + VStack(spacing: 0) { + SliderPreivew( + showsSliderPreview: $showsSliderPreview, + sliderValue: $sliderValue, previewURLs: previewURLs, range: range, + isReversed: isReversed, fetchPreviewURLsAction: fetchPreviewURLsAction + ) + VStack { + HStack { + Text(isReversed ? "\(Int(range.upperBound))" : "\(Int(range.lowerBound))") + .fontWeight(.medium).font(.caption).padding() + Slider( + value: $sliderValue, in: range, step: 1, + onEditingChanged: { showsSliderPreview = $0 } + ) + // wtaf is happening here? + .frame(width: DeviceUtil.windowW * 0.6) + .rotationEffect(.init(degrees: isReversed ? 180 : 0)) + Text(isReversed ? "\(Int(range.lowerBound))" : "\(Int(range.upperBound))") + .fontWeight(.medium).font(.caption).padding() + } + .padding(.horizontal).padding(.bottom) + } + } + .glassEffect() + } else { + VStack(spacing: 0) { + SliderPreivew( + showsSliderPreview: $showsSliderPreview, + sliderValue: $sliderValue, previewURLs: previewURLs, range: range, + isReversed: isReversed, fetchPreviewURLsAction: fetchPreviewURLsAction + ) + VStack { + HStack { + Text(isReversed ? "\(Int(range.upperBound))" : "\(Int(range.lowerBound))") + .fontWeight(.medium).font(.caption).padding() + Slider( + value: $sliderValue, in: range, step: 1, + onEditingChanged: { showsSliderPreview = $0 } + ) + .rotationEffect(.init(degrees: isReversed ? 180 : 0)) + Text(isReversed ? "\(Int(range.lowerBound))" : "\(Int(range.upperBound))") + .fontWeight(.medium).font(.caption).padding() + } + .padding(.horizontal).padding(.bottom) } - .padding(.horizontal).padding(.bottom) } + .background(Material.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 20)) } - .background(.thinMaterial) } } } From 71448fc740716d7295a2bf957aa7f90f279804f2 Mon Sep 17 00:00:00 2001 From: Zack Asahina <59857887+aalberrty@users.noreply.github.com> Date: Tue, 29 Jul 2025 06:18:37 +0800 Subject: [PATCH 12/40] update test action to xcode beta --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5015dbcb..8e7ffac3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,10 +2,10 @@ name: Test on: [push, workflow_dispatch] env: SCHEME_NAME: 'EhPanda' - DEVELOPER_DIR: /Applications/Xcode_16.2.app + DEVELOPER_DIR: /Applications/Xcode_26_beta.app jobs: Test: - runs-on: macos-15 + runs-on: macos-latest if: ${{ !contains(github.event.head_commit.message, '[skip test]') }} steps: - name: Checkout From 772afe2b6672ba148b62b9f1db13646ddc585789 Mon Sep 17 00:00:00 2001 From: Zack Asahina <59857887+aalberrty@users.noreply.github.com> Date: Tue, 29 Jul 2025 06:21:12 +0800 Subject: [PATCH 13/40] Update test.yml --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e7ffac3..5f33efeb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ env: DEVELOPER_DIR: /Applications/Xcode_26_beta.app jobs: Test: - runs-on: macos-latest + runs-on: macos-15 if: ${{ !contains(github.event.head_commit.message, '[skip test]') }} steps: - name: Checkout From 3b31db78bc99f7d02a4aff11f12f5641f7d7a366 Mon Sep 17 00:00:00 2001 From: Zack Asahina <59857887+aalberrty@users.noreply.github.com> Date: Tue, 29 Jul 2025 06:24:40 +0800 Subject: [PATCH 14/40] Update test.yml --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5f33efeb..83d5a138 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Install iOS 26 Platform + run: xcodebuild -downloadPlatform iOS - name: Show Xcode version run: xcodebuild -version - name: Run tests From 2479ff4ad63a22f1d45bfd9ee61a3cfc6e2dd10e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 29 Jul 2025 09:54:23 +0800 Subject: [PATCH 15/40] Update CI to accept multiple maintainers --- .github/workflows/dependencies.yml | 2 +- .github/workflows/deploy.yml | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index 85197711..6e3fe890 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -19,7 +19,7 @@ jobs: with: forceResolution: true failWhenOutdated: false - xcodePath: '/Applications/Xcode_16.2.app' + xcodePath: '/Applications/Xcode_16.4.app' - name: Create Pull Request if: steps.resolution.outputs.dependenciesChanged == 'true' uses: peter-evans/create-pull-request@v7 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5f72eb55..79b3fe96 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,7 +5,7 @@ on: - main types: [closed] env: - DEVELOPER_DIR: /Applications/Xcode_16.2.app + DEVELOPER_DIR: /Applications/Xcode_16.4.app APP_VERSION: '2.8.0' SCHEME_NAME: 'EhPanda' ALTSTORE_JSON_PATH: './AltStore.json' @@ -20,7 +20,10 @@ env: jobs: Deploy: runs-on: macos-15 - if: github.event.pull_request.merged == true && github.event.pull_request.user.login == 'chihchy' + if: | + github.event.pull_request.merged == true && ( + github.event.pull_request.user.login == 'aalberrty' || + github.event.pull_request.user.login == 'chihchy') steps: - name: Checkout uses: actions/checkout@v4 From a84196c87f493d482596255b661d7915882fbd9c Mon Sep 17 00:00:00 2001 From: ZackYu <59857887+aalberrty@users.noreply.github.com> Date: Wed, 30 Jul 2025 01:07:50 +0800 Subject: [PATCH 16/40] fixed reading view slider bottom padding under liquid glass effect and added interactive button. ready to release. --- EhPanda/View/Reading/Support/ControlPanel.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/EhPanda/View/Reading/Support/ControlPanel.swift b/EhPanda/View/Reading/Support/ControlPanel.swift index 67f6ceb2..c2850878 100644 --- a/EhPanda/View/Reading/Support/ControlPanel.swift +++ b/EhPanda/View/Reading/Support/ControlPanel.swift @@ -128,7 +128,7 @@ private struct UpperPanel: View { .foregroundColor(.primary) .frame(width: 44, height: 44) } - .glassEffect() + .glassEffect(.regular.interactive()) .padding(.leading, 20) } else { Button(action: dismissAction) { @@ -239,7 +239,7 @@ private struct UpperPanel: View { } .padding(.horizontal, 16) .padding(.vertical, 8) - .glassEffect() + .glassEffect(.regular.interactive()) .padding(.trailing, 20) } else { HStack(spacing: 20) { @@ -352,7 +352,7 @@ private struct LowerPanel: View { .font(.title2) .frame(width: 44, height: 44) } - .glassEffect(in: RoundedRectangle(cornerRadius: 22)) + .glassEffect(.regular.interactive()) .gesture(dismissGesture) .opacity(showsSliderPreview ? 0 : 1) } else { @@ -390,10 +390,10 @@ private struct LowerPanel: View { Text(isReversed ? "\(Int(range.lowerBound))" : "\(Int(range.upperBound))") .fontWeight(.medium).font(.caption).padding() } - .padding(.horizontal).padding(.bottom) + .padding(.horizontal) //.padding(.bottom) + .glassEffect() } } - .glassEffect() } else { VStack(spacing: 0) { SliderPreivew( From acdc6471db6d1deb47173521f6860b26e47c7d9e Mon Sep 17 00:00:00 2001 From: Zack Asahina <59857887+aalberrty@users.noreply.github.com> Date: Wed, 30 Jul 2025 01:14:00 +0800 Subject: [PATCH 17/40] getting ready for 2.8.0 release --- .github/workflows/deploy.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 79b3fe96..ec7e6ae6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,7 +5,7 @@ on: - main types: [closed] env: - DEVELOPER_DIR: /Applications/Xcode_16.4.app + DEVELOPER_DIR: /Applications/Xcode_26_beta.app APP_VERSION: '2.8.0' SCHEME_NAME: 'EhPanda' ALTSTORE_JSON_PATH: './AltStore.json' @@ -31,6 +31,8 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Install iOS 26 Platform + run: xcodebuild -downloadPlatform iOS - name: Show Xcode version run: xcodebuild -version - name: Run tests From 92bcebe30c6dbbd0cbe242b88c308f5e48d829b4 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 20 Aug 2025 14:06:30 +0800 Subject: [PATCH 18/40] Remove names --- EhPanda/App/EhPandaApp.swift | 2 -- EhPanda/App/Generated/Strings.swift | 14 -------------- EhPanda/App/Tools/Clients/AppDelegateClient.swift | 2 -- .../App/Tools/Clients/AuthorizationClient.swift | 2 -- EhPanda/App/Tools/Clients/ClipboardClient.swift | 2 -- EhPanda/App/Tools/Clients/CookieClient.swift | 2 -- EhPanda/App/Tools/Clients/DFClient.swift | 2 -- EhPanda/App/Tools/Clients/DatabaseClient.swift | 2 -- EhPanda/App/Tools/Clients/DeviceClient.swift | 2 -- EhPanda/App/Tools/Clients/FileClient.swift | 2 -- EhPanda/App/Tools/Clients/HapticsClient.swift | 2 -- EhPanda/App/Tools/Clients/ImageClient.swift | 2 -- EhPanda/App/Tools/Clients/LibraryClient.swift | 2 -- EhPanda/App/Tools/Clients/LoggerClient.swift | 2 -- .../App/Tools/Clients/UIApplicationClient.swift | 2 -- EhPanda/App/Tools/Clients/URLClient.swift | 2 -- EhPanda/App/Tools/Clients/UserDefaultsClient.swift | 2 -- EhPanda/App/Tools/ColorCodable.swift | 1 - EhPanda/App/Tools/Defaults.swift | 2 -- EhPanda/App/Tools/EnvironmentKeys.swift | 2 -- EhPanda/App/Tools/EquatableVoid.swift | 2 -- .../App/Tools/Extensions/AlertKit_Extension.swift | 2 -- EhPanda/App/Tools/Extensions/Extensions.swift | 2 -- .../App/Tools/Extensions/Reducer_Extension.swift | 2 -- .../Extensions/SwiftUINavigation_Extension.swift | 2 -- .../Tools/Extensions/TTProgressHUD_Extension.swift | 2 -- EhPanda/App/Tools/Extensions/ViewModifiers.swift | 2 -- EhPanda/App/Tools/IdentifiableBox.swift | 2 -- EhPanda/App/Tools/Parser.swift | 2 -- EhPanda/App/Tools/Utilities/AppUtil.swift | 2 -- EhPanda/App/Tools/Utilities/CookieUtil.swift | 2 -- EhPanda/App/Tools/Utilities/DeviceUtil.swift | 2 -- EhPanda/App/Tools/Utilities/FileUtil.swift | 2 -- EhPanda/App/Tools/Utilities/HapticsUtil.swift | 2 -- EhPanda/App/Tools/Utilities/MarkdownUtil.swift | 2 -- EhPanda/App/Tools/Utilities/URLUtil.swift | 2 -- EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift | 2 -- EhPanda/App/de.lproj/InfoPlist.strings | 3 --- EhPanda/App/de.lproj/Localizable.strings | 3 --- EhPanda/App/en.lproj/Constant.strings | 7 ------- EhPanda/App/en.lproj/InfoPlist.strings | 3 --- EhPanda/App/en.lproj/Localizable.strings | 3 --- EhPanda/App/ja.lproj/InfoPlist.strings | 3 --- EhPanda/App/ja.lproj/Localizable.strings | 3 --- EhPanda/App/ko.lproj/InfoPlist.strings | 3 --- EhPanda/App/ko.lproj/Localizable.strings | 3 --- EhPanda/App/zh-Hans.lproj/InfoPlist.strings | 3 --- EhPanda/App/zh-Hans.lproj/Localizable.strings | 3 --- EhPanda/App/zh-Hant-HK.lproj/InfoPlist.strings | 3 --- EhPanda/App/zh-Hant-HK.lproj/Localizable.strings | 3 --- EhPanda/App/zh-Hant-TW.lproj/InfoPlist.strings | 3 --- EhPanda/App/zh-Hant-TW.lproj/Localizable.strings | 3 --- EhPanda/App/zh-Hant.lproj/InfoPlist.strings | 3 --- EhPanda/App/zh-Hant.lproj/Localizable.strings | 3 --- EhPanda/DataFlow/AppDelegateReducer.swift | 2 -- EhPanda/DataFlow/AppLockReducer.swift | 2 -- EhPanda/DataFlow/AppReducer.swift | 2 -- EhPanda/DataFlow/AppRouteReducer.swift | 2 -- EhPanda/DataFlow/Heap.swift | 2 -- .../FileManager+ApplicationSupport.swift | 3 --- .../NSManagedObjectModel+Compatible.swift | 3 --- .../NSManagedObjectModel+Resource.swift | 3 --- .../NSPersistentStoreCoordinator+SQLite.swift | 3 --- .../MODefinition/AppEnvMO+CoreDataClass.swift | 2 -- .../MODefinition/AppEnvMO+CoreDataProperties.swift | 2 -- .../GalleryDetailMO+CoreDataClass.swift | 2 -- .../GalleryDetailMO+CoreDataProperties.swift | 2 -- .../MODefinition/GalleryMO+CoreDataClass.swift | 2 -- .../GalleryMO+CoreDataProperties.swift | 2 -- .../GalleryStateMO+CoreDataClass.swift | 2 -- .../GalleryStateMO+CoreDataProperties.swift | 2 -- .../Database/Migration/CoreDataMigrationStep.swift | 3 --- .../Migration/CoreDataMigrationVersion.swift | 3 --- EhPanda/Database/Migration/CoreDataMigrator.swift | 3 --- .../Policies/Model5toModel6MigrationPolicy.swift | 2 -- EhPanda/Database/Persistence.swift | 2 -- EhPanda/Models/Gallery/Category.swift | 2 -- EhPanda/Models/Gallery/Gallery.swift | 2 -- EhPanda/Models/Gallery/GalleryArchive.swift | 2 -- EhPanda/Models/Gallery/GalleryComment.swift | 2 -- EhPanda/Models/Gallery/GalleryDetail.swift | 2 -- EhPanda/Models/Gallery/GalleryState.swift | 2 -- EhPanda/Models/Gallery/GalleryTorrent.swift | 2 -- EhPanda/Models/Gallery/Language.swift | 2 -- EhPanda/Models/Persistent/AppEnv.swift | 2 -- EhPanda/Models/Persistent/Filter.swift | 2 -- EhPanda/Models/Persistent/Greeting.swift | 2 -- EhPanda/Models/Persistent/Setting.swift | 2 -- EhPanda/Models/Persistent/User.swift | 2 -- EhPanda/Models/Support/AppError.swift | 2 -- EhPanda/Models/Support/BrowsingCountry.swift | 2 -- EhPanda/Models/Support/EhSetting.swift | 2 -- EhPanda/Models/Support/LiveText.swift | 2 -- EhPanda/Models/Support/Misc.swift | 2 -- .../Tags/EhTagTranslationDatabaseModel.swift | 2 -- EhPanda/Models/Tags/TagDetail.swift | 2 -- EhPanda/Models/Tags/TagNamespace.swift | 2 -- EhPanda/Models/Tags/TagSuggestion.swift | 2 -- EhPanda/Models/Tags/TagTranslation.swift | 2 -- EhPanda/Models/Tags/TagTranslator.swift | 2 -- EhPanda/Models/Tags/TranslatableLanguage.swift | 2 -- EhPanda/Network/DFExtensions.swift | 2 -- EhPanda/Network/DFRequest.swift | 2 -- EhPanda/Network/DFStreamHandler.swift | 2 -- EhPanda/Network/DFURLProtocol.swift | 2 -- EhPanda/Network/DomainResolver.swift | 2 -- EhPanda/Network/Request.swift | 2 -- EhPanda/View/Detail/Archives/ArchivesReducer.swift | 2 -- EhPanda/View/Detail/Archives/ArchivesView.swift | 2 -- EhPanda/View/Detail/Comments/CommentsReducer.swift | 2 -- EhPanda/View/Detail/Comments/CommentsView.swift | 2 -- EhPanda/View/Detail/Components/LinkedText.swift | 1 - .../View/Detail/Components/PostCommentView.swift | 2 -- EhPanda/View/Detail/Components/RatingView.swift | 2 -- EhPanda/View/Detail/Components/TagDetailView.swift | 2 -- EhPanda/View/Detail/DetailReducer.swift | 2 -- .../Detail/DetailSearch/DetailSearchReducer.swift | 2 -- .../Detail/DetailSearch/DetailSearchView.swift | 2 -- EhPanda/View/Detail/DetailView.swift | 2 -- .../Detail/GalleryInfos/GalleryInfosReducer.swift | 2 -- .../Detail/GalleryInfos/GalleryInfosView.swift | 2 -- EhPanda/View/Detail/Previews/PreviewsReducer.swift | 2 -- EhPanda/View/Detail/Previews/PreviewsView.swift | 2 -- EhPanda/View/Detail/Torrents/TorrentsReducer.swift | 2 -- EhPanda/View/Detail/Torrents/TorrentsView.swift | 2 -- EhPanda/View/Favorites/FavoritesReducer.swift | 2 -- EhPanda/View/Favorites/FavoritesView.swift | 2 -- EhPanda/View/Home/Frontpage/FrontpageReducer.swift | 2 -- EhPanda/View/Home/Frontpage/FrontpageView.swift | 2 -- EhPanda/View/Home/History/HistoryReducer.swift | 2 -- EhPanda/View/Home/History/HistoryView.swift | 2 -- EhPanda/View/Home/HomeReducer.swift | 2 -- EhPanda/View/Home/HomeView.swift | 2 -- EhPanda/View/Home/Popular/PopularReducer.swift | 2 -- EhPanda/View/Home/Popular/PopularView.swift | 2 -- EhPanda/View/Home/Toplists/ToplistsReducer.swift | 2 -- EhPanda/View/Home/Toplists/ToplistsView.swift | 2 -- EhPanda/View/Home/Watched/WatchedReducer.swift | 2 -- EhPanda/View/Home/Watched/WatchedView.swift | 2 -- EhPanda/View/Migration/MigrationReducer.swift | 2 -- EhPanda/View/Migration/MigrationView.swift | 2 -- EhPanda/View/Reading/ReadingReducer.swift | 5 +---- EhPanda/View/Reading/ReadingView.swift | 3 --- EhPanda/View/Reading/Support/AdvancedList.swift | 3 --- EhPanda/View/Reading/Support/ControlPanel.swift | 2 -- .../View/Reading/Support/GestureCoordinator.swift | 4 +--- EhPanda/View/Reading/Support/ImageStackView.swift | 4 +--- EhPanda/View/Reading/Support/LiveTextView.swift | 2 -- EhPanda/View/Reading/Support/PageCoordinator.swift | 4 +--- .../Reading/Support/ReadingViewExtensions.swift | 4 +--- .../View/Reading/Support/ReadingViewModel.swift | 4 +--- EhPanda/View/Search/SearchReducer.swift | 2 -- EhPanda/View/Search/SearchRootReducer.swift | 2 -- EhPanda/View/Search/SearchRootView.swift | 2 -- EhPanda/View/Search/SearchView.swift | 2 -- .../View/Search/Support/QuickSearchReducer.swift | 2 -- EhPanda/View/Search/Support/QuickSearchView.swift | 2 -- .../AccountSetting/AccountSettingReducer.swift | 2 -- .../AccountSetting/AccountSettingView.swift | 2 -- .../AppearanceSettingReducer.swift | 2 -- .../AppearanceSetting/AppearanceSettingView.swift | 2 -- EhPanda/View/Setting/Components/AboutView.swift | 10 ---------- .../Setting/Components/LaboratorySettingView.swift | 2 -- .../Setting/Components/ReadingSettingView.swift | 2 -- EhPanda/View/Setting/Components/WebView.swift | 2 -- .../View/Setting/EhSetting/EhSettingReducer.swift | 2 -- EhPanda/View/Setting/EhSetting/EhSettingView.swift | 2 -- .../GeneralSetting/GeneralSettingReducer.swift | 2 -- .../GeneralSetting/GeneralSettingView.swift | 2 -- EhPanda/View/Setting/Login/LoginReducer.swift | 2 -- EhPanda/View/Setting/Login/LoginView.swift | 2 -- EhPanda/View/Setting/Logs/LogsReducer.swift | 2 -- EhPanda/View/Setting/Logs/LogsView.swift | 2 -- EhPanda/View/Setting/SettingReducer.swift | 2 -- EhPanda/View/Setting/SettingView.swift | 2 -- EhPanda/View/Support/Components/ActivityView.swift | 2 -- EhPanda/View/Support/Components/AlertView.swift | 2 -- EhPanda/View/Support/Components/CategoryView.swift | 2 -- .../Support/Components/Cells/GalleryCardCell.swift | 2 -- .../Components/Cells/GalleryDetailCell.swift | 2 -- .../Components/Cells/GalleryHistoryCell.swift | 2 -- .../Components/Cells/GalleryRankingCell.swift | 2 -- .../Components/Cells/GalleryThumbnailCell.swift | 2 -- EhPanda/View/Support/Components/GenericList.swift | 2 -- EhPanda/View/Support/Components/Placeholder.swift | 2 -- .../View/Support/Components/SettingTextField.swift | 2 -- EhPanda/View/Support/Components/SubSection.swift | 2 -- EhPanda/View/Support/Components/TagCloudView.swift | 1 - .../Support/Components/TagSuggestionView.swift | 2 -- EhPanda/View/Support/Components/ToolbarItems.swift | 2 -- EhPanda/View/Support/Components/WaveForm.swift | 1 - EhPanda/View/Support/FiltersReducer.swift | 2 -- EhPanda/View/Support/FiltersView.swift | 2 -- EhPanda/View/Support/NewDawnView.swift | 2 -- EhPanda/View/TabBar/TabBarReducer.swift | 2 -- EhPanda/View/TabBar/TabBarView.swift | 2 -- EhPandaTests/Models/HTMLFilename.swift | 2 -- EhPandaTests/Models/ListParserTestType.swift | 2 -- EhPandaTests/Models/TestError.swift | 2 -- EhPandaTests/Resources/Utility/Extensions.swift | 2 -- EhPandaTests/Resources/Utility/TestHelper.swift | 2 -- .../Parser/Gallery/GalleryDetailParserTests.swift | 2 -- .../Gallery/GalleryImageURLParserTests.swift | 2 -- .../Parser/Gallery/GalleryMPVKeysParserTests.swift | 2 -- .../Tests/Parser/List/ListParserTests.swift | 2 -- .../Parser/Other/BanIntervalParserTests.swift | 2 -- .../Tests/Parser/Other/EhSettingParserTests.swift | 2 -- .../Tests/Parser/Other/GreetingParserTests.swift | 2 -- README.md | 3 --- READMEs/README.chs.md | 3 --- READMEs/README.cht.md | 3 --- READMEs/README.de.md | 3 --- READMEs/README.jpn.md | 3 --- READMEs/README.ko.md | 3 --- ShareExtension/ShareViewController.swift | 2 -- 215 files changed, 6 insertions(+), 489 deletions(-) diff --git a/EhPanda/App/EhPandaApp.swift b/EhPanda/App/EhPandaApp.swift index 4aa69ab1..80ec487f 100644 --- a/EhPanda/App/EhPandaApp.swift +++ b/EhPanda/App/EhPandaApp.swift @@ -2,8 +2,6 @@ // EhPandaApp.swift // EhPanda // -// Created by 荒木辰造 on R 2/10/28. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 32d2e8f3..7202a961 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -94,8 +94,6 @@ internal enum L10n { internal static let chihchy = L10n.tr("Constant", "app.code_level_contributor.link.chihchy", fallback: "https://github.com/chihchy") /// https://github.com/Jimmy-Prime internal static let jimmyPrime = L10n.tr("Constant", "app.code_level_contributor.link.Jimmy-Prime", fallback: "https://github.com/Jimmy-Prime") - /// https://github.com/tatsuz0u - internal static let tatsuz0u = L10n.tr("Constant", "app.code_level_contributor.link.tatsuz0u", fallback: "https://github.com/tatsuz0u") /// https://github.com/vvbbnn00 internal static let vvbbnn00 = L10n.tr("Constant", "app.code_level_contributor.link.vvbbnn00", fallback: "https://github.com/vvbbnn00") /// https://github.com/xioxin @@ -106,8 +104,6 @@ internal enum L10n { internal static let chihchy = L10n.tr("Constant", "app.code_level_contributor.text.chihchy", fallback: "Chihchy") /// Jimmy Prime internal static let jimmyPrime = L10n.tr("Constant", "app.code_level_contributor.text.Jimmy-Prime", fallback: "Jimmy Prime") - /// Tatsuzo Araki - internal static let tatsuz0u = L10n.tr("Constant", "app.code_level_contributor.text.tatsuz0u", fallback: "Tatsuzo Araki") /// vvbbnn00 internal static let vvbbnn00 = L10n.tr("Constant", "app.code_level_contributor.text.vvbbnn00", fallback: "vvbbnn00") /// xioxin @@ -168,8 +164,6 @@ internal enum L10n { internal static let neKoOuO = L10n.tr("Constant", "app.translation_contributor.link.NeKoOuO", fallback: "https://github.com/NeKoOuO") /// https://github.com/PaulHaeussler internal static let paulHaeussler = L10n.tr("Constant", "app.translation_contributor.link.paulHaeussler", fallback: "https://github.com/PaulHaeussler") - /// https://github.com/tatsuz0u - internal static let tatsuz0u = L10n.tr("Constant", "app.translation_contributor.link.tatsuz0u", fallback: "https://github.com/tatsuz0u") } internal enum Text { /// caxerx @@ -180,8 +174,6 @@ internal enum L10n { internal static let neKoOuO = L10n.tr("Constant", "app.translation_contributor.text.NeKoOuO", fallback: "ɴᴇᴋᴏ") /// PaulHaeussler internal static let paulHaeussler = L10n.tr("Constant", "app.translation_contributor.text.paulHaeussler", fallback: "PaulHaeussler") - /// Tatsuzo Araki - internal static let tatsuz0u = L10n.tr("Constant", "app.translation_contributor.text.tatsuz0u", fallback: "Tatsuzo Araki") } } } @@ -191,8 +183,6 @@ internal enum L10n { internal static let galleryUnavailable = L10n.tr("Constant", "website.response.gallery_unavailable", fallback: "This gallery has been removed or is unavailable.") /// Constant.strings /// EhPanda - /// - /// Created by 荒木辰造 on R 4/02/04. internal static let hathClientNotFound = L10n.tr("Constant", "website.response.hath_client_not_found", fallback: "You must have a H@H client assigned to your account to use this feature.") /// Your H@H client appears to be offline. Turn it on, then try again. internal static let hathClientNotOnline = L10n.tr("Constant", "website.response.hath_client_not_online", fallback: "Your H@H client appears to be offline. Turn it on, then try again.") @@ -204,8 +194,6 @@ internal enum L10n { internal enum InfoPlist { /// InfoPlist.strings /// EhPanda - /// - /// Created by 荒木辰造 on R 3/02/09. internal static let nsFaceIDUsageDescription = L10n.tr("InfoPlist", "NSFaceIDUsageDescription", fallback: "We need this permission to provide Face ID option while unlocking the App.") /// We need this permission to save images to your photo library. internal static let nsPhotoLibraryAddUsageDescription = L10n.tr("InfoPlist", "NSPhotoLibraryAddUsageDescription", fallback: "We need this permission to save images to your photo library.") @@ -705,8 +693,6 @@ internal enum L10n { internal enum Description { /// Localizable.strings /// EhPanda - /// - /// Created by 荒木辰造 on R 2/12/25. internal static let and = L10n.tr("Localizable", "enum.ban_interval.description.and", fallback: "and") } } diff --git a/EhPanda/App/Tools/Clients/AppDelegateClient.swift b/EhPanda/App/Tools/Clients/AppDelegateClient.swift index 4c7c7521..877b4bce 100644 --- a/EhPanda/App/Tools/Clients/AppDelegateClient.swift +++ b/EhPanda/App/Tools/Clients/AppDelegateClient.swift @@ -2,8 +2,6 @@ // AppDelegateClient.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/09. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/AuthorizationClient.swift b/EhPanda/App/Tools/Clients/AuthorizationClient.swift index b5ad3097..18a92940 100644 --- a/EhPanda/App/Tools/Clients/AuthorizationClient.swift +++ b/EhPanda/App/Tools/Clients/AuthorizationClient.swift @@ -2,8 +2,6 @@ // AuthorizationClient.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/03. -// import Combine import LocalAuthentication diff --git a/EhPanda/App/Tools/Clients/ClipboardClient.swift b/EhPanda/App/Tools/Clients/ClipboardClient.swift index 5cbf527b..fabfbce4 100644 --- a/EhPanda/App/Tools/Clients/ClipboardClient.swift +++ b/EhPanda/App/Tools/Clients/ClipboardClient.swift @@ -2,8 +2,6 @@ // ClipboardClient.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/19. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/CookieClient.swift b/EhPanda/App/Tools/Clients/CookieClient.swift index 82209a27..b2d89ff4 100644 --- a/EhPanda/App/Tools/Clients/CookieClient.swift +++ b/EhPanda/App/Tools/Clients/CookieClient.swift @@ -2,8 +2,6 @@ // CookieClient.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/02. -// import Foundation import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/DFClient.swift b/EhPanda/App/Tools/Clients/DFClient.swift index b1be5960..c74e2c33 100644 --- a/EhPanda/App/Tools/Clients/DFClient.swift +++ b/EhPanda/App/Tools/Clients/DFClient.swift @@ -2,8 +2,6 @@ // DFClient.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/02. -// import Foundation import Kingfisher diff --git a/EhPanda/App/Tools/Clients/DatabaseClient.swift b/EhPanda/App/Tools/Clients/DatabaseClient.swift index dacabc28..bd0d55ba 100644 --- a/EhPanda/App/Tools/Clients/DatabaseClient.swift +++ b/EhPanda/App/Tools/Clients/DatabaseClient.swift @@ -2,8 +2,6 @@ // DatabaseClient.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/02. -// import SwiftUI import Combine diff --git a/EhPanda/App/Tools/Clients/DeviceClient.swift b/EhPanda/App/Tools/Clients/DeviceClient.swift index f35ab2e5..27bc1265 100644 --- a/EhPanda/App/Tools/Clients/DeviceClient.swift +++ b/EhPanda/App/Tools/Clients/DeviceClient.swift @@ -2,8 +2,6 @@ // DeviceClient.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/09. -// import SwiftUI import Dependencies diff --git a/EhPanda/App/Tools/Clients/FileClient.swift b/EhPanda/App/Tools/Clients/FileClient.swift index b4160ea0..7ee659b6 100644 --- a/EhPanda/App/Tools/Clients/FileClient.swift +++ b/EhPanda/App/Tools/Clients/FileClient.swift @@ -2,8 +2,6 @@ // FileClient.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/03. -// import Combine import Foundation diff --git a/EhPanda/App/Tools/Clients/HapticsClient.swift b/EhPanda/App/Tools/Clients/HapticsClient.swift index f71bfe75..25e4d7d5 100644 --- a/EhPanda/App/Tools/Clients/HapticsClient.swift +++ b/EhPanda/App/Tools/Clients/HapticsClient.swift @@ -2,8 +2,6 @@ // HapticsClient.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/02. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 4037d1aa..1f14343b 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -2,8 +2,6 @@ // ImageClient.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/23. -// import Photos import SwiftUI diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/EhPanda/App/Tools/Clients/LibraryClient.swift index 327ff0c6..85ccc136 100644 --- a/EhPanda/App/Tools/Clients/LibraryClient.swift +++ b/EhPanda/App/Tools/Clients/LibraryClient.swift @@ -2,8 +2,6 @@ // LibraryClient.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/02. -// import SwiftUI import Combine diff --git a/EhPanda/App/Tools/Clients/LoggerClient.swift b/EhPanda/App/Tools/Clients/LoggerClient.swift index 3712d615..7b6c8711 100644 --- a/EhPanda/App/Tools/Clients/LoggerClient.swift +++ b/EhPanda/App/Tools/Clients/LoggerClient.swift @@ -2,8 +2,6 @@ // LoggerClient.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/02. -// import ComposableArchitecture diff --git a/EhPanda/App/Tools/Clients/UIApplicationClient.swift b/EhPanda/App/Tools/Clients/UIApplicationClient.swift index 3f75ad67..f7b163bf 100644 --- a/EhPanda/App/Tools/Clients/UIApplicationClient.swift +++ b/EhPanda/App/Tools/Clients/UIApplicationClient.swift @@ -2,8 +2,6 @@ // UIApplicationClient.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/02. -// import SwiftUI import Combine diff --git a/EhPanda/App/Tools/Clients/URLClient.swift b/EhPanda/App/Tools/Clients/URLClient.swift index 62b01c36..5a9136a2 100644 --- a/EhPanda/App/Tools/Clients/URLClient.swift +++ b/EhPanda/App/Tools/Clients/URLClient.swift @@ -2,8 +2,6 @@ // URLClient.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/16. -// import SwiftUI import Dependencies diff --git a/EhPanda/App/Tools/Clients/UserDefaultsClient.swift b/EhPanda/App/Tools/Clients/UserDefaultsClient.swift index 72ba48d3..f886a8b2 100644 --- a/EhPanda/App/Tools/Clients/UserDefaultsClient.swift +++ b/EhPanda/App/Tools/Clients/UserDefaultsClient.swift @@ -2,8 +2,6 @@ // UserDefaultsClient.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/02. -// import Foundation import ComposableArchitecture diff --git a/EhPanda/App/Tools/ColorCodable.swift b/EhPanda/App/Tools/ColorCodable.swift index d8c1f3fc..01ec6843 100644 --- a/EhPanda/App/Tools/ColorCodable.swift +++ b/EhPanda/App/Tools/ColorCodable.swift @@ -2,7 +2,6 @@ // ColorCodable.swift // EhPanda // -// Created by 荒木辰造 on R 3/02/03. // Copied from https://brunowernimont.me/howtos/make-swiftui-color-codable // diff --git a/EhPanda/App/Tools/Defaults.swift b/EhPanda/App/Tools/Defaults.swift index 889d25e8..e13c56cc 100644 --- a/EhPanda/App/Tools/Defaults.swift +++ b/EhPanda/App/Tools/Defaults.swift @@ -2,8 +2,6 @@ // Defaults.swift // EhPanda // -// Created by 荒木辰造 on R 2/11/22. -// import UIKit import Foundation diff --git a/EhPanda/App/Tools/EnvironmentKeys.swift b/EhPanda/App/Tools/EnvironmentKeys.swift index 174ab0d1..583f6cc3 100644 --- a/EhPanda/App/Tools/EnvironmentKeys.swift +++ b/EhPanda/App/Tools/EnvironmentKeys.swift @@ -2,8 +2,6 @@ // EnvironmentKeys.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/21. -// import SwiftUI diff --git a/EhPanda/App/Tools/EquatableVoid.swift b/EhPanda/App/Tools/EquatableVoid.swift index c2a7942b..d2d3dc74 100644 --- a/EhPanda/App/Tools/EquatableVoid.swift +++ b/EhPanda/App/Tools/EquatableVoid.swift @@ -2,8 +2,6 @@ // EquatableVoid.swift // EhPanda // -// Created by Chihchy on 2024/10/27. -// import Foundation diff --git a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift b/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift index 055a3cc5..16574b85 100644 --- a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift +++ b/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift @@ -2,8 +2,6 @@ // AlertKit_Extension.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/08. -// import SwiftUI import AlertKit diff --git a/EhPanda/App/Tools/Extensions/Extensions.swift b/EhPanda/App/Tools/Extensions/Extensions.swift index a837f9a2..00d8275b 100644 --- a/EhPanda/App/Tools/Extensions/Extensions.swift +++ b/EhPanda/App/Tools/Extensions/Extensions.swift @@ -2,8 +2,6 @@ // Extensions.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/08. -// import SwiftUI import Foundation diff --git a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift b/EhPanda/App/Tools/Extensions/Reducer_Extension.swift index 8ce8ea12..c1ea651a 100644 --- a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift +++ b/EhPanda/App/Tools/Extensions/Reducer_Extension.swift @@ -2,8 +2,6 @@ // Reducer_Extension.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/02. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift b/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift index 79100afe..d9eb463c 100644 --- a/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift +++ b/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift @@ -2,8 +2,6 @@ // SwiftUINavigation_Extension.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/13. -// import SwiftUI import TTProgressHUD diff --git a/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift b/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift index b9948d23..80c0910f 100644 --- a/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift +++ b/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift @@ -2,8 +2,6 @@ // TTProgressHUD_Extension.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/15. -// import TTProgressHUD diff --git a/EhPanda/App/Tools/Extensions/ViewModifiers.swift b/EhPanda/App/Tools/Extensions/ViewModifiers.swift index 5d44616e..cf2be369 100644 --- a/EhPanda/App/Tools/Extensions/ViewModifiers.swift +++ b/EhPanda/App/Tools/Extensions/ViewModifiers.swift @@ -2,8 +2,6 @@ // ViewModifiers.swift // EhPanda // -// Created by 荒木辰造 on R 2/12/06. -// import SwiftUI import Kingfisher diff --git a/EhPanda/App/Tools/IdentifiableBox.swift b/EhPanda/App/Tools/IdentifiableBox.swift index 2a758f8b..91d12811 100644 --- a/EhPanda/App/Tools/IdentifiableBox.swift +++ b/EhPanda/App/Tools/IdentifiableBox.swift @@ -2,8 +2,6 @@ // IdentifiableBox.swift // EhPanda // -// Created by Chihchy on 2024/10/27. -// import Foundation diff --git a/EhPanda/App/Tools/Parser.swift b/EhPanda/App/Tools/Parser.swift index 9a876146..7782db41 100644 --- a/EhPanda/App/Tools/Parser.swift +++ b/EhPanda/App/Tools/Parser.swift @@ -2,8 +2,6 @@ // Parser.swift // EhPanda // -// Created by 荒木辰造 on R 2/12/26. -// import Kanna import OpenCC diff --git a/EhPanda/App/Tools/Utilities/AppUtil.swift b/EhPanda/App/Tools/Utilities/AppUtil.swift index 79f1b9a9..a73c6601 100644 --- a/EhPanda/App/Tools/Utilities/AppUtil.swift +++ b/EhPanda/App/Tools/Utilities/AppUtil.swift @@ -2,8 +2,6 @@ // AppUtil.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/02. -// import Foundation diff --git a/EhPanda/App/Tools/Utilities/CookieUtil.swift b/EhPanda/App/Tools/Utilities/CookieUtil.swift index a07f7ebe..c7a0f58f 100644 --- a/EhPanda/App/Tools/Utilities/CookieUtil.swift +++ b/EhPanda/App/Tools/Utilities/CookieUtil.swift @@ -2,8 +2,6 @@ // CookieUtil.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/02. -// import Foundation diff --git a/EhPanda/App/Tools/Utilities/DeviceUtil.swift b/EhPanda/App/Tools/Utilities/DeviceUtil.swift index c860e0d1..531f900a 100644 --- a/EhPanda/App/Tools/Utilities/DeviceUtil.swift +++ b/EhPanda/App/Tools/Utilities/DeviceUtil.swift @@ -2,8 +2,6 @@ // DeviceUtil.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/02. -// import SwiftUI import Foundation diff --git a/EhPanda/App/Tools/Utilities/FileUtil.swift b/EhPanda/App/Tools/Utilities/FileUtil.swift index 2b76549b..6521d3c3 100644 --- a/EhPanda/App/Tools/Utilities/FileUtil.swift +++ b/EhPanda/App/Tools/Utilities/FileUtil.swift @@ -2,8 +2,6 @@ // FileUtil.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/02. -// import Foundation diff --git a/EhPanda/App/Tools/Utilities/HapticsUtil.swift b/EhPanda/App/Tools/Utilities/HapticsUtil.swift index e436b136..b0a3f3df 100644 --- a/EhPanda/App/Tools/Utilities/HapticsUtil.swift +++ b/EhPanda/App/Tools/Utilities/HapticsUtil.swift @@ -2,8 +2,6 @@ // HapticsUtil.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/02. -// import SwiftUI import AudioToolbox diff --git a/EhPanda/App/Tools/Utilities/MarkdownUtil.swift b/EhPanda/App/Tools/Utilities/MarkdownUtil.swift index 4283f596..03c5e4dd 100644 --- a/EhPanda/App/Tools/Utilities/MarkdownUtil.swift +++ b/EhPanda/App/Tools/Utilities/MarkdownUtil.swift @@ -2,8 +2,6 @@ // MarkdownUtil.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/20. -// import CasePaths import CommonMark diff --git a/EhPanda/App/Tools/Utilities/URLUtil.swift b/EhPanda/App/Tools/Utilities/URLUtil.swift index 98d40b3b..0fae2053 100644 --- a/EhPanda/App/Tools/Utilities/URLUtil.swift +++ b/EhPanda/App/Tools/Utilities/URLUtil.swift @@ -2,8 +2,6 @@ // URLUtil.swift // EhPanda // -// Created by 荒木辰造 on R 3/12/31. -// import Foundation diff --git a/EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift b/EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift index a286acbc..f727a2d0 100644 --- a/EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift +++ b/EhPanda/App/Tools/Utilities/UserDefaultsUtil.swift @@ -2,8 +2,6 @@ // UserDefaultsUtil.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/02. -// import Foundation diff --git a/EhPanda/App/de.lproj/InfoPlist.strings b/EhPanda/App/de.lproj/InfoPlist.strings index b46c7bb9..f8593f4b 100644 --- a/EhPanda/App/de.lproj/InfoPlist.strings +++ b/EhPanda/App/de.lproj/InfoPlist.strings @@ -1,9 +1,6 @@ /* InfoPlist.strings EhPanda - - Created by Paul Häussler on 23.07.2021. - */ "NSFaceIDUsageDescription" = "Diese Berechtigung ist notwendig um Face ID zum Entsperren der Anwendung verwenden zu können."; diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index a3c104a6..43db93b8 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -1,9 +1,6 @@ /* Localizable.strings EhPanda - - Created by Paul Häussler on 23.07.2021. - */ // MARK: BanInterval diff --git a/EhPanda/App/en.lproj/Constant.strings b/EhPanda/App/en.lproj/Constant.strings index 36a8e203..29f37839 100644 --- a/EhPanda/App/en.lproj/Constant.strings +++ b/EhPanda/App/en.lproj/Constant.strings @@ -1,9 +1,6 @@ /* Constant.strings EhPanda - - Created by 荒木辰造 on R 4/02/04. - */ // MARK: Website response @@ -36,24 +33,20 @@ "app.special_thanks.text.honjow" = "honjow"; // Code level contributor -"app.code_level_contributor.link.tatsuz0u" = "https://github.com/tatsuz0u"; "app.code_level_contributor.link.chihchy" = "https://github.com/chihchy"; "app.code_level_contributor.link.Jimmy-Prime" = "https://github.com/Jimmy-Prime"; "app.code_level_contributor.link.xioxin" = "https://github.com/xioxin"; "app.code_level_contributor.link.vvbbnn00" = "https://github.com/vvbbnn00"; -"app.code_level_contributor.text.tatsuz0u" = "Tatsuzo Araki"; "app.code_level_contributor.text.chihchy" = "Chihchy"; "app.code_level_contributor.text.Jimmy-Prime" = "Jimmy Prime"; "app.code_level_contributor.text.xioxin" = "xioxin"; "app.code_level_contributor.text.vvbbnn00" = "vvbbnn00"; // Translation contributor -"app.translation_contributor.link.tatsuz0u" = "https://github.com/tatsuz0u"; "app.translation_contributor.link.nebulosa-cat" = "https://github.com/Nebulosa-Cat"; "app.translation_contributor.link.paulHaeussler" = "https://github.com/PaulHaeussler"; "app.translation_contributor.link.caxerx" = "https://github.com/caxerx"; "app.translation_contributor.link.NeKoOuO" = "https://github.com/NeKoOuO"; -"app.translation_contributor.text.tatsuz0u" = "Tatsuzo Araki"; "app.translation_contributor.text.nebulosa-cat" = "雲豹 ΦωΦ"; "app.translation_contributor.text.paulHaeussler" = "PaulHaeussler"; "app.translation_contributor.text.caxerx" = "caxerx"; diff --git a/EhPanda/App/en.lproj/InfoPlist.strings b/EhPanda/App/en.lproj/InfoPlist.strings index d290e3a2..a924bb3b 100644 --- a/EhPanda/App/en.lproj/InfoPlist.strings +++ b/EhPanda/App/en.lproj/InfoPlist.strings @@ -1,9 +1,6 @@ /* InfoPlist.strings EhPanda - - Created by 荒木辰造 on R 3/02/09. - */ "NSFaceIDUsageDescription" = "We need this permission to provide Face ID option while unlocking the App."; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index 002c5f9f..bc56f1d1 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -1,9 +1,6 @@ /* Localizable.strings EhPanda - - Created by 荒木辰造 on R 2/12/25. - */ // MARK: BanInterval diff --git a/EhPanda/App/ja.lproj/InfoPlist.strings b/EhPanda/App/ja.lproj/InfoPlist.strings index 8e28dccd..db744b85 100644 --- a/EhPanda/App/ja.lproj/InfoPlist.strings +++ b/EhPanda/App/ja.lproj/InfoPlist.strings @@ -1,9 +1,6 @@ /* InfoPlist.strings EhPanda - - Created by 荒木辰造 on R 3/02/09. - */ "NSFaceIDUsageDescription" = "アプリアンロック認証時に Face ID オプションを提供するにはこの権限が必要です"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 3d5a6d5f..362be102 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -1,9 +1,6 @@ /* Localizable.strings EhPanda - - Created by 荒木辰造 on R 2/12/25. - */ // MARK: BanInterval diff --git a/EhPanda/App/ko.lproj/InfoPlist.strings b/EhPanda/App/ko.lproj/InfoPlist.strings index 84ca61cf..d96b58bb 100644 --- a/EhPanda/App/ko.lproj/InfoPlist.strings +++ b/EhPanda/App/ko.lproj/InfoPlist.strings @@ -1,9 +1,6 @@ /* InfoPlist.strings EhPanda - - Created by Hokyee Jau on R 3/07/11. - */ "NSFaceIDUsageDescription" = "이 권한을 허용해야 앱 잠금 해제할때 Face ID 옵션을 제공합니다."; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index c2b9c8a7..5d652367 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -1,9 +1,6 @@ /* Localizable.strings EhPanda - - Created by Hokyee Jau on R 3/07/11. - */ // MARK: BanInterval diff --git a/EhPanda/App/zh-Hans.lproj/InfoPlist.strings b/EhPanda/App/zh-Hans.lproj/InfoPlist.strings index be9f1f45..b01d42fc 100644 --- a/EhPanda/App/zh-Hans.lproj/InfoPlist.strings +++ b/EhPanda/App/zh-Hans.lproj/InfoPlist.strings @@ -1,9 +1,6 @@ /* InfoPlist.strings EhPanda - - Created by 荒木辰造 on R 3/02/09. - */ "NSFaceIDUsageDescription" = "需要此权限以在解锁 App 时提供 Face ID 选项"; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index 6f35ae88..b6ecccdf 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -1,9 +1,6 @@ /* Localizable.strings EhPanda - - Created by 荒木辰造 on R 2/12/25. - */ // MARK: BanInterval diff --git a/EhPanda/App/zh-Hant-HK.lproj/InfoPlist.strings b/EhPanda/App/zh-Hant-HK.lproj/InfoPlist.strings index e6182020..c19da437 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/InfoPlist.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/InfoPlist.strings @@ -1,9 +1,6 @@ /* InfoPlist.strings EhPanda - - Created by 荒木辰造 on R 3/02/09. - */ "NSFaceIDUsageDescription" = "我們需要您提供 Face ID 權限來使用 Face ID 解鎖"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index 315b04b3..c0d19029 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -1,9 +1,6 @@ /* Localizable.strings EhPanda - - Created by 荒木辰造 on R 2/12/25. - */ // MARK: BanInterval diff --git a/EhPanda/App/zh-Hant-TW.lproj/InfoPlist.strings b/EhPanda/App/zh-Hant-TW.lproj/InfoPlist.strings index 75ad2545..af772906 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/InfoPlist.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/InfoPlist.strings @@ -1,9 +1,6 @@ /* InfoPlist.strings EhPanda - - Created by 荒木辰造 on R 3/02/09. - */ "NSFaceIDUsageDescription" = "我們需要您提供 Face ID 權限來使用 Face ID 解鎖"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index e8f6a99a..a035bada 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -1,9 +1,6 @@ /* Localizable.strings EhPanda - - Created by 荒木辰造 on R 2/12/25. - */ // MARK: BanInterval diff --git a/EhPanda/App/zh-Hant.lproj/InfoPlist.strings b/EhPanda/App/zh-Hant.lproj/InfoPlist.strings index e6182020..c19da437 100644 --- a/EhPanda/App/zh-Hant.lproj/InfoPlist.strings +++ b/EhPanda/App/zh-Hant.lproj/InfoPlist.strings @@ -1,9 +1,6 @@ /* InfoPlist.strings EhPanda - - Created by 荒木辰造 on R 3/02/09. - */ "NSFaceIDUsageDescription" = "我們需要您提供 Face ID 權限來使用 Face ID 解鎖"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index 0cf7840a..67060d21 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -1,9 +1,6 @@ /* Localizable.strings EhPanda - - Created by 荒木辰造 on R 2/12/25. - */ // MARK: BanInterval diff --git a/EhPanda/DataFlow/AppDelegateReducer.swift b/EhPanda/DataFlow/AppDelegateReducer.swift index 6e06aaf6..4b413e10 100644 --- a/EhPanda/DataFlow/AppDelegateReducer.swift +++ b/EhPanda/DataFlow/AppDelegateReducer.swift @@ -2,8 +2,6 @@ // AppDelegateReducer.swift // EhPanda // -// Created by 荒木辰造 on R 3/12/25. -// import SwiftUI import SwiftyBeaver diff --git a/EhPanda/DataFlow/AppLockReducer.swift b/EhPanda/DataFlow/AppLockReducer.swift index 6a3cf513..14f67490 100644 --- a/EhPanda/DataFlow/AppLockReducer.swift +++ b/EhPanda/DataFlow/AppLockReducer.swift @@ -2,8 +2,6 @@ // AppLockReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/05. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift index ec163e3e..5333c8a2 100644 --- a/EhPanda/DataFlow/AppReducer.swift +++ b/EhPanda/DataFlow/AppReducer.swift @@ -2,8 +2,6 @@ // AppReducer.swift // EhPanda // -// Created by 荒木辰造 on R 3/12/25. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/DataFlow/AppRouteReducer.swift b/EhPanda/DataFlow/AppRouteReducer.swift index 34cd7b34..94c04e57 100644 --- a/EhPanda/DataFlow/AppRouteReducer.swift +++ b/EhPanda/DataFlow/AppRouteReducer.swift @@ -2,8 +2,6 @@ // AppRouteReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/08. -// import SwiftUI import TTProgressHUD diff --git a/EhPanda/DataFlow/Heap.swift b/EhPanda/DataFlow/Heap.swift index cfdb6dd9..21fff54f 100755 --- a/EhPanda/DataFlow/Heap.swift +++ b/EhPanda/DataFlow/Heap.swift @@ -2,8 +2,6 @@ // Heap.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/06. -// private final class Reference: Equatable { var value: T diff --git a/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift b/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift index 73fcb8ab..13afa9a0 100755 --- a/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift +++ b/EhPanda/Database/Extensions/FileManager/FileManager+ApplicationSupport.swift @@ -2,9 +2,6 @@ // FileManager+ApplicationSupport.swift // CoreDataMigration-Example // -// Created by William Boles on 17/01/2019. -// Copyright © 2019 William Boles. All rights reserved. -// import Foundation diff --git a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift b/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift index 7392cc92..4e6d00b9 100755 --- a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift +++ b/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Compatible.swift @@ -2,9 +2,6 @@ // NSManagedObjectModel+Compatible.swift // CoreDataMigration-Example // -// Created by William Boles on 02/01/2019. -// Copyright © 2019 William Boles. All rights reserved. -// import Foundation import CoreData diff --git a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift b/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift index a4e9dd93..683efe31 100755 --- a/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift +++ b/EhPanda/Database/Extensions/NSManagedObjectModel/NSManagedObjectModel+Resource.swift @@ -2,9 +2,6 @@ // NSManagedObjectModel+Resource.swift // CoreDataMigration-Example // -// Created by William Boles on 02/01/2019. -// Copyright © 2019 William Boles. All rights reserved. -// import Foundation import CoreData diff --git a/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift b/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift index 9b325282..10820f45 100755 --- a/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift +++ b/EhPanda/Database/Extensions/NSPersistentStoreCoordinator/NSPersistentStoreCoordinator+SQLite.swift @@ -2,9 +2,6 @@ // NSPersistentStoreCoordinator+SQLite.swift // CoreDataMigration-Example // -// Created by William Boles on 15/09/2017. -// Copyright © 2017 William Boles. All rights reserved. -// import CoreData diff --git a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift index 3c4ec1e1..43a3f32c 100644 --- a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift @@ -2,8 +2,6 @@ // AppEnvMO+CoreDataClass.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/10. -// import CoreData diff --git a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift index 55901017..63f3c000 100644 --- a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift +++ b/EhPanda/Database/MODefinition/AppEnvMO+CoreDataProperties.swift @@ -2,8 +2,6 @@ // AppEnvMO+CoreDataProperties.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/10. -// import CoreData diff --git a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift index fffb307c..8b108ac1 100644 --- a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift @@ -2,8 +2,6 @@ // GalleryDetailMO+CoreDataClass.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/04. -// import CoreData diff --git a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift index 91958f3f..b4b1ee26 100644 --- a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift +++ b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataProperties.swift @@ -2,8 +2,6 @@ // GalleryDetailMO+CoreDataProperties.swift // EhPanda // -// Created by 荒木辰造 on R 3/06/29. -// import CoreData diff --git a/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift index 8ed1db85..01fb5db5 100644 --- a/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift @@ -2,8 +2,6 @@ // GalleryMO+CoreDataClass.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/04. -// import CoreData diff --git a/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift index 280ccae3..11167c46 100644 --- a/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift +++ b/EhPanda/Database/MODefinition/GalleryMO+CoreDataProperties.swift @@ -2,8 +2,6 @@ // GalleryMO+CoreDataProperties.swift // EhPanda // -// Created by 荒木辰造 on R 3/06/29. -// import CoreData diff --git a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift index 096cd94e..f3bec387 100644 --- a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift @@ -2,8 +2,6 @@ // GalleryStateMO+CoreDataClass.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/09. -// import SwiftUI import CoreData diff --git a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift index 91fed52f..06b5e8e1 100644 --- a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift +++ b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataProperties.swift @@ -2,8 +2,6 @@ // GalleryStateMO+CoreDataProperties.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/09. -// import CoreData diff --git a/EhPanda/Database/Migration/CoreDataMigrationStep.swift b/EhPanda/Database/Migration/CoreDataMigrationStep.swift index 07cd865d..9842587e 100755 --- a/EhPanda/Database/Migration/CoreDataMigrationStep.swift +++ b/EhPanda/Database/Migration/CoreDataMigrationStep.swift @@ -2,9 +2,6 @@ // CoreDataMigrationStep.swift // CoreDataMigration-Example // -// Created by William Boles on 11/09/2017. -// Copyright © 2017 William Boles. All rights reserved. -// import CoreData diff --git a/EhPanda/Database/Migration/CoreDataMigrationVersion.swift b/EhPanda/Database/Migration/CoreDataMigrationVersion.swift index 89674a60..b3496841 100755 --- a/EhPanda/Database/Migration/CoreDataMigrationVersion.swift +++ b/EhPanda/Database/Migration/CoreDataMigrationVersion.swift @@ -2,9 +2,6 @@ // CoreDataVersion.swift // CoreDataMigration-Example // -// Created by William Boles on 02/01/2019. -// Copyright © 2019 William Boles. All rights reserved. -// import Foundation import CoreData diff --git a/EhPanda/Database/Migration/CoreDataMigrator.swift b/EhPanda/Database/Migration/CoreDataMigrator.swift index 00987b97..5694e6be 100755 --- a/EhPanda/Database/Migration/CoreDataMigrator.swift +++ b/EhPanda/Database/Migration/CoreDataMigrator.swift @@ -2,9 +2,6 @@ // CoreDataMigrator.swift // CoreDataMigration-Example // -// Created by William Boles on 11/09/2017. -// Copyright © 2017 William Boles. All rights reserved. -// import CoreData diff --git a/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift b/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift index b205ef78..dd610226 100644 --- a/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift +++ b/EhPanda/Database/Migration/Policies/Model5toModel6MigrationPolicy.swift @@ -2,8 +2,6 @@ // Model5toModel6MigrationPolicy.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/24. -// import CoreData diff --git a/EhPanda/Database/Persistence.swift b/EhPanda/Database/Persistence.swift index 7ab83b1e..434f903c 100644 --- a/EhPanda/Database/Persistence.swift +++ b/EhPanda/Database/Persistence.swift @@ -2,8 +2,6 @@ // Persistence.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/04. -// import CoreData diff --git a/EhPanda/Models/Gallery/Category.swift b/EhPanda/Models/Gallery/Category.swift index d795ff86..7c150416 100644 --- a/EhPanda/Models/Gallery/Category.swift +++ b/EhPanda/Models/Gallery/Category.swift @@ -2,8 +2,6 @@ // Category.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/01. -// import SwiftUI diff --git a/EhPanda/Models/Gallery/Gallery.swift b/EhPanda/Models/Gallery/Gallery.swift index 7ea18386..8d0aacb6 100644 --- a/EhPanda/Models/Gallery/Gallery.swift +++ b/EhPanda/Models/Gallery/Gallery.swift @@ -2,8 +2,6 @@ // Gallery.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/01. -// import SwiftUI diff --git a/EhPanda/Models/Gallery/GalleryArchive.swift b/EhPanda/Models/Gallery/GalleryArchive.swift index b22e5fd1..eccd3923 100644 --- a/EhPanda/Models/Gallery/GalleryArchive.swift +++ b/EhPanda/Models/Gallery/GalleryArchive.swift @@ -2,8 +2,6 @@ // GalleryArchive.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/01. -// import Foundation diff --git a/EhPanda/Models/Gallery/GalleryComment.swift b/EhPanda/Models/Gallery/GalleryComment.swift index 074bde6c..75428e3f 100644 --- a/EhPanda/Models/Gallery/GalleryComment.swift +++ b/EhPanda/Models/Gallery/GalleryComment.swift @@ -2,8 +2,6 @@ // GalleryComment.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/01. -// import Foundation diff --git a/EhPanda/Models/Gallery/GalleryDetail.swift b/EhPanda/Models/Gallery/GalleryDetail.swift index 15ad46df..a6cf9928 100644 --- a/EhPanda/Models/Gallery/GalleryDetail.swift +++ b/EhPanda/Models/Gallery/GalleryDetail.swift @@ -2,8 +2,6 @@ // GalleryDetail.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/01. -// import Foundation diff --git a/EhPanda/Models/Gallery/GalleryState.swift b/EhPanda/Models/Gallery/GalleryState.swift index f151b670..f649f16a 100644 --- a/EhPanda/Models/Gallery/GalleryState.swift +++ b/EhPanda/Models/Gallery/GalleryState.swift @@ -2,8 +2,6 @@ // GalleryState.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/01. -// import SwiftUI import Foundation diff --git a/EhPanda/Models/Gallery/GalleryTorrent.swift b/EhPanda/Models/Gallery/GalleryTorrent.swift index 15c66e77..c6dfe5ed 100644 --- a/EhPanda/Models/Gallery/GalleryTorrent.swift +++ b/EhPanda/Models/Gallery/GalleryTorrent.swift @@ -2,8 +2,6 @@ // GalleryTorrent.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/01. -// import Foundation diff --git a/EhPanda/Models/Gallery/Language.swift b/EhPanda/Models/Gallery/Language.swift index 59835c84..dada937d 100644 --- a/EhPanda/Models/Gallery/Language.swift +++ b/EhPanda/Models/Gallery/Language.swift @@ -2,8 +2,6 @@ // Language.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/30. -// enum Language: String, Codable { static let allExcludedCases: [Self] = [ diff --git a/EhPanda/Models/Persistent/AppEnv.swift b/EhPanda/Models/Persistent/AppEnv.swift index e77dafef..cdc7d433 100644 --- a/EhPanda/Models/Persistent/AppEnv.swift +++ b/EhPanda/Models/Persistent/AppEnv.swift @@ -2,8 +2,6 @@ // AppEnv.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/04. -// struct AppEnv: Codable, Equatable { let user: User diff --git a/EhPanda/Models/Persistent/Filter.swift b/EhPanda/Models/Persistent/Filter.swift index 0c03406f..ef7d4e09 100644 --- a/EhPanda/Models/Persistent/Filter.swift +++ b/EhPanda/Models/Persistent/Filter.swift @@ -2,8 +2,6 @@ // Filter.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/08. -// import SwiftUI diff --git a/EhPanda/Models/Persistent/Greeting.swift b/EhPanda/Models/Persistent/Greeting.swift index 882de5cb..8b65bf94 100644 --- a/EhPanda/Models/Persistent/Greeting.swift +++ b/EhPanda/Models/Persistent/Greeting.swift @@ -2,8 +2,6 @@ // Greeting.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/01. -// import Foundation diff --git a/EhPanda/Models/Persistent/Setting.swift b/EhPanda/Models/Persistent/Setting.swift index fd3c17e9..4ea020c0 100644 --- a/EhPanda/Models/Persistent/Setting.swift +++ b/EhPanda/Models/Persistent/Setting.swift @@ -2,8 +2,6 @@ // Setting.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/09. -// import SwiftUI import Foundation diff --git a/EhPanda/Models/Persistent/User.swift b/EhPanda/Models/Persistent/User.swift index 8a4e90dd..f6a082db 100644 --- a/EhPanda/Models/Persistent/User.swift +++ b/EhPanda/Models/Persistent/User.swift @@ -2,8 +2,6 @@ // User.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/03. -// import Foundation diff --git a/EhPanda/Models/Support/AppError.swift b/EhPanda/Models/Support/AppError.swift index 20a0c5d5..d315149f 100644 --- a/EhPanda/Models/Support/AppError.swift +++ b/EhPanda/Models/Support/AppError.swift @@ -2,8 +2,6 @@ // AppError.swift // EhPanda // -// Created by 荒木辰造 on R 2/12/26. -// import Foundation import SFSafeSymbols diff --git a/EhPanda/Models/Support/BrowsingCountry.swift b/EhPanda/Models/Support/BrowsingCountry.swift index f6a53d17..31730762 100644 --- a/EhPanda/Models/Support/BrowsingCountry.swift +++ b/EhPanda/Models/Support/BrowsingCountry.swift @@ -2,8 +2,6 @@ // BrowsingCountry.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/30. -// import Foundation diff --git a/EhPanda/Models/Support/EhSetting.swift b/EhPanda/Models/Support/EhSetting.swift index 1e7c81ab..1d5a1944 100644 --- a/EhPanda/Models/Support/EhSetting.swift +++ b/EhPanda/Models/Support/EhSetting.swift @@ -2,8 +2,6 @@ // EhSetting.swift // EhSetting // -// Created by 荒木辰造 on R 3/08/08. -// // MARK: EhSetting struct EhSetting: Equatable { diff --git a/EhPanda/Models/Support/LiveText.swift b/EhPanda/Models/Support/LiveText.swift index a7415ff9..db6271fb 100644 --- a/EhPanda/Models/Support/LiveText.swift +++ b/EhPanda/Models/Support/LiveText.swift @@ -2,8 +2,6 @@ // LiveText.swift // EhPanda // -// Created by xioxin on 2022/2/12. -// import SwiftUI import Foundation diff --git a/EhPanda/Models/Support/Misc.swift b/EhPanda/Models/Support/Misc.swift index 80336b86..46d14119 100644 --- a/EhPanda/Models/Support/Misc.swift +++ b/EhPanda/Models/Support/Misc.swift @@ -2,8 +2,6 @@ // Misc.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/15. -// import CasePaths import Foundation diff --git a/EhPanda/Models/Tags/EhTagTranslationDatabaseModel.swift b/EhPanda/Models/Tags/EhTagTranslationDatabaseModel.swift index a499a72c..fa30aa91 100644 --- a/EhPanda/Models/Tags/EhTagTranslationDatabaseModel.swift +++ b/EhPanda/Models/Tags/EhTagTranslationDatabaseModel.swift @@ -2,8 +2,6 @@ // EhTagTranslationDatabaseModel.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/20. -// import Foundation diff --git a/EhPanda/Models/Tags/TagDetail.swift b/EhPanda/Models/Tags/TagDetail.swift index fb7a13c9..4a4a7da1 100644 --- a/EhPanda/Models/Tags/TagDetail.swift +++ b/EhPanda/Models/Tags/TagDetail.swift @@ -2,8 +2,6 @@ // TagDetail.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/23. -// import Foundation diff --git a/EhPanda/Models/Tags/TagNamespace.swift b/EhPanda/Models/Tags/TagNamespace.swift index 5ca56e04..b423b1d8 100644 --- a/EhPanda/Models/Tags/TagNamespace.swift +++ b/EhPanda/Models/Tags/TagNamespace.swift @@ -2,8 +2,6 @@ // TagCategory.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/01. -// enum TagNamespace: String, Codable, CaseIterable { case reclass diff --git a/EhPanda/Models/Tags/TagSuggestion.swift b/EhPanda/Models/Tags/TagSuggestion.swift index 192196c9..37cb849f 100644 --- a/EhPanda/Models/Tags/TagSuggestion.swift +++ b/EhPanda/Models/Tags/TagSuggestion.swift @@ -2,8 +2,6 @@ // TagSuggestion.swift // EhPanda // -// Created by xioxin on 2022/2/15. -// import SwiftUI diff --git a/EhPanda/Models/Tags/TagTranslation.swift b/EhPanda/Models/Tags/TagTranslation.swift index 7cfbd200..b50e01a9 100644 --- a/EhPanda/Models/Tags/TagTranslation.swift +++ b/EhPanda/Models/Tags/TagTranslation.swift @@ -2,8 +2,6 @@ // TagTranslation.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/26. -// import OpenCC import Foundation diff --git a/EhPanda/Models/Tags/TagTranslator.swift b/EhPanda/Models/Tags/TagTranslator.swift index 15815470..31572119 100644 --- a/EhPanda/Models/Tags/TagTranslator.swift +++ b/EhPanda/Models/Tags/TagTranslator.swift @@ -2,8 +2,6 @@ // TagTranslator.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/04. -// import Foundation diff --git a/EhPanda/Models/Tags/TranslatableLanguage.swift b/EhPanda/Models/Tags/TranslatableLanguage.swift index 38b9c118..bfae94a8 100644 --- a/EhPanda/Models/Tags/TranslatableLanguage.swift +++ b/EhPanda/Models/Tags/TranslatableLanguage.swift @@ -2,8 +2,6 @@ // TranslatableLanguage.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/01. -// import Foundation diff --git a/EhPanda/Network/DFExtensions.swift b/EhPanda/Network/DFExtensions.swift index 7ea1fccd..1171a2d8 100644 --- a/EhPanda/Network/DFExtensions.swift +++ b/EhPanda/Network/DFExtensions.swift @@ -2,8 +2,6 @@ // DFExtensions.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/13. -// import Foundation import DeprecatedAPI diff --git a/EhPanda/Network/DFRequest.swift b/EhPanda/Network/DFRequest.swift index 4e8e2442..cdfc8579 100644 --- a/EhPanda/Network/DFRequest.swift +++ b/EhPanda/Network/DFRequest.swift @@ -2,8 +2,6 @@ // DFRequest.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/13. -// import Foundation diff --git a/EhPanda/Network/DFStreamHandler.swift b/EhPanda/Network/DFStreamHandler.swift index ee0cd802..cd7404ab 100644 --- a/EhPanda/Network/DFStreamHandler.swift +++ b/EhPanda/Network/DFStreamHandler.swift @@ -2,8 +2,6 @@ // DFStreamEventHandler.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/13. -// import Foundation diff --git a/EhPanda/Network/DFURLProtocol.swift b/EhPanda/Network/DFURLProtocol.swift index c31db3af..30d79877 100644 --- a/EhPanda/Network/DFURLProtocol.swift +++ b/EhPanda/Network/DFURLProtocol.swift @@ -2,8 +2,6 @@ // DFURLProtocol.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/13. -// import Foundation diff --git a/EhPanda/Network/DomainResolver.swift b/EhPanda/Network/DomainResolver.swift index cbedc241..02a20e75 100644 --- a/EhPanda/Network/DomainResolver.swift +++ b/EhPanda/Network/DomainResolver.swift @@ -2,8 +2,6 @@ // DomainResolver.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/13. -// struct DomainResolver { static func resolve(domain: String) -> String? { diff --git a/EhPanda/Network/Request.swift b/EhPanda/Network/Request.swift index d2f06895..f70b746a 100644 --- a/EhPanda/Network/Request.swift +++ b/EhPanda/Network/Request.swift @@ -2,8 +2,6 @@ // PopularItemsRequest.swift // EhPanda // -// Created by 荒木辰造 on R 2/12/26. -// import Kanna import Combine diff --git a/EhPanda/View/Detail/Archives/ArchivesReducer.swift b/EhPanda/View/Detail/Archives/ArchivesReducer.swift index 024221d6..b98ed509 100644 --- a/EhPanda/View/Detail/Archives/ArchivesReducer.swift +++ b/EhPanda/View/Detail/Archives/ArchivesReducer.swift @@ -2,8 +2,6 @@ // ArchivesReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/19. -// import Foundation import TTProgressHUD diff --git a/EhPanda/View/Detail/Archives/ArchivesView.swift b/EhPanda/View/Detail/Archives/ArchivesView.swift index 0d6efb9d..81966f7b 100644 --- a/EhPanda/View/Detail/Archives/ArchivesView.swift +++ b/EhPanda/View/Detail/Archives/ArchivesView.swift @@ -2,8 +2,6 @@ // ArchivesView.swift // EhPanda // -// Created by 荒木辰造 on R 3/02/06. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Detail/Comments/CommentsReducer.swift b/EhPanda/View/Detail/Comments/CommentsReducer.swift index ff6330a2..432d412c 100644 --- a/EhPanda/View/Detail/Comments/CommentsReducer.swift +++ b/EhPanda/View/Detail/Comments/CommentsReducer.swift @@ -2,8 +2,6 @@ // CommentsReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/16. -// import Foundation import TTProgressHUD diff --git a/EhPanda/View/Detail/Comments/CommentsView.swift b/EhPanda/View/Detail/Comments/CommentsView.swift index 34be8ce0..9a809df9 100644 --- a/EhPanda/View/Detail/Comments/CommentsView.swift +++ b/EhPanda/View/Detail/Comments/CommentsView.swift @@ -2,8 +2,6 @@ // CommentsView.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/02. -// import SwiftUI import Kingfisher diff --git a/EhPanda/View/Detail/Components/LinkedText.swift b/EhPanda/View/Detail/Components/LinkedText.swift index 17a01b56..9e92ae5d 100644 --- a/EhPanda/View/Detail/Components/LinkedText.swift +++ b/EhPanda/View/Detail/Components/LinkedText.swift @@ -2,7 +2,6 @@ // LinkedText.swift // EhPanda // -// Created by 荒木辰造 on R 3/02/10. // Copied from https://gist.github.com/mjm/0581781f85db45b05e8e2c5c33696f88 // diff --git a/EhPanda/View/Detail/Components/PostCommentView.swift b/EhPanda/View/Detail/Components/PostCommentView.swift index 641c6a01..b298271b 100644 --- a/EhPanda/View/Detail/Components/PostCommentView.swift +++ b/EhPanda/View/Detail/Components/PostCommentView.swift @@ -2,8 +2,6 @@ // PostCommentView.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/03. -// import SwiftUI diff --git a/EhPanda/View/Detail/Components/RatingView.swift b/EhPanda/View/Detail/Components/RatingView.swift index 6dd85536..56098966 100644 --- a/EhPanda/View/Detail/Components/RatingView.swift +++ b/EhPanda/View/Detail/Components/RatingView.swift @@ -2,8 +2,6 @@ // RatingView.swift // EhPanda // -// Created by 荒木辰造 on R 2/11/29. -// import SwiftUI diff --git a/EhPanda/View/Detail/Components/TagDetailView.swift b/EhPanda/View/Detail/Components/TagDetailView.swift index 4121511d..6b61d45d 100644 --- a/EhPanda/View/Detail/Components/TagDetailView.swift +++ b/EhPanda/View/Detail/Components/TagDetailView.swift @@ -2,8 +2,6 @@ // TagDetailView.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/21. -// import SwiftUI import Kingfisher diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index d8c256bd..5e6c9823 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -2,8 +2,6 @@ // DetailReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/10. -// import SwiftUI import Foundation diff --git a/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift b/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift index 1b9c8313..70e330c2 100644 --- a/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift +++ b/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift @@ -2,8 +2,6 @@ // DetailSearchReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/12. -// import ComposableArchitecture diff --git a/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift b/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift index 7a65a612..da4cccf5 100644 --- a/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift +++ b/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift @@ -2,8 +2,6 @@ // DetailSearchView.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/12. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index 5f065d6d..6c58532d 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -2,8 +2,6 @@ // DetailView.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/10. -// import SwiftUI import Kingfisher diff --git a/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift b/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift index ba30dddb..4f0d4f05 100644 --- a/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift +++ b/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift @@ -2,8 +2,6 @@ // GalleryInfosReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/23. -// import TTProgressHUD import ComposableArchitecture diff --git a/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift b/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift index 4d55c735..d28c870f 100644 --- a/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift +++ b/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift @@ -2,8 +2,6 @@ // GalleryInfosView.swift // EhPanda // -// Created by 荒木辰造 on R 3/08/15. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Detail/Previews/PreviewsReducer.swift b/EhPanda/View/Detail/Previews/PreviewsReducer.swift index f25e6f30..fa41832d 100644 --- a/EhPanda/View/Detail/Previews/PreviewsReducer.swift +++ b/EhPanda/View/Detail/Previews/PreviewsReducer.swift @@ -2,8 +2,6 @@ // PreviewsReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/16. -// import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Detail/Previews/PreviewsView.swift b/EhPanda/View/Detail/Previews/PreviewsView.swift index 97c01490..8df2e8a9 100644 --- a/EhPanda/View/Detail/Previews/PreviewsView.swift +++ b/EhPanda/View/Detail/Previews/PreviewsView.swift @@ -2,8 +2,6 @@ // PreviewsView.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/10. -// import SwiftUI import Kingfisher diff --git a/EhPanda/View/Detail/Torrents/TorrentsReducer.swift b/EhPanda/View/Detail/Torrents/TorrentsReducer.swift index b520718d..8423ee3d 100644 --- a/EhPanda/View/Detail/Torrents/TorrentsReducer.swift +++ b/EhPanda/View/Detail/Torrents/TorrentsReducer.swift @@ -2,8 +2,6 @@ // TorrentsReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/19. -// import Foundation import TTProgressHUD diff --git a/EhPanda/View/Detail/Torrents/TorrentsView.swift b/EhPanda/View/Detail/Torrents/TorrentsView.swift index 512fc2fd..8d1c9df2 100644 --- a/EhPanda/View/Detail/Torrents/TorrentsView.swift +++ b/EhPanda/View/Detail/Torrents/TorrentsView.swift @@ -2,8 +2,6 @@ // TorrentsView.swift // EhPanda // -// Created by 荒木辰造 on R 3/02/02. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Favorites/FavoritesReducer.swift b/EhPanda/View/Favorites/FavoritesReducer.swift index a7b85d60..0ccf39e5 100644 --- a/EhPanda/View/Favorites/FavoritesReducer.swift +++ b/EhPanda/View/Favorites/FavoritesReducer.swift @@ -2,8 +2,6 @@ // FavoritesReducer.swift // EhPanda // -// Created by 荒木辰造 on R 3/12/29. -// import SwiftUI import IdentifiedCollections diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift index 25300ad6..b864bd9d 100644 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -2,8 +2,6 @@ // FavoritesView.swift // EhPanda // -// Created by 荒木辰造 on R 3/12/13. -// import SwiftUI import AlertKit diff --git a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift index e3490b49..f27c5aee 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift @@ -2,8 +2,6 @@ // FrontpageReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/08. -// import ComposableArchitecture diff --git a/EhPanda/View/Home/Frontpage/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift index 64191521..571b15c7 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageView.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageView.swift @@ -2,8 +2,6 @@ // FrontpageView.swift // EhPanda // -// Created by 荒木辰造 on R 3/12/18. -// import SwiftUI import AlertKit diff --git a/EhPanda/View/Home/History/HistoryReducer.swift b/EhPanda/View/Home/History/HistoryReducer.swift index 3070977f..df4ffc27 100644 --- a/EhPanda/View/Home/History/HistoryReducer.swift +++ b/EhPanda/View/Home/History/HistoryReducer.swift @@ -2,8 +2,6 @@ // HistoryReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/09. -// import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Home/History/HistoryView.swift b/EhPanda/View/Home/History/HistoryView.swift index 30cad029..da046d56 100644 --- a/EhPanda/View/Home/History/HistoryView.swift +++ b/EhPanda/View/Home/History/HistoryView.swift @@ -2,8 +2,6 @@ // HistoryView.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/09. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Home/HomeReducer.swift b/EhPanda/View/Home/HomeReducer.swift index 189d3d18..6165d918 100644 --- a/EhPanda/View/Home/HomeReducer.swift +++ b/EhPanda/View/Home/HomeReducer.swift @@ -2,8 +2,6 @@ // HomeReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/05. -// import SwiftUI import Kingfisher diff --git a/EhPanda/View/Home/HomeView.swift b/EhPanda/View/Home/HomeView.swift index 3fde0f22..09d1940c 100644 --- a/EhPanda/View/Home/HomeView.swift +++ b/EhPanda/View/Home/HomeView.swift @@ -2,8 +2,6 @@ // HomeView.swift // EhPanda // -// Created by 荒木辰造 on R 3/12/13. -// import SwiftUI import Kingfisher diff --git a/EhPanda/View/Home/Popular/PopularReducer.swift b/EhPanda/View/Home/Popular/PopularReducer.swift index 6e56f3ca..1b8df2a4 100644 --- a/EhPanda/View/Home/Popular/PopularReducer.swift +++ b/EhPanda/View/Home/Popular/PopularReducer.swift @@ -2,8 +2,6 @@ // PopularReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/09. -// import ComposableArchitecture diff --git a/EhPanda/View/Home/Popular/PopularView.swift b/EhPanda/View/Home/Popular/PopularView.swift index cebfaf22..3c7e3c56 100644 --- a/EhPanda/View/Home/Popular/PopularView.swift +++ b/EhPanda/View/Home/Popular/PopularView.swift @@ -2,8 +2,6 @@ // PopularView.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/09. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Home/Toplists/ToplistsReducer.swift b/EhPanda/View/Home/Toplists/ToplistsReducer.swift index 52da374f..b36ca8fe 100644 --- a/EhPanda/View/Home/Toplists/ToplistsReducer.swift +++ b/EhPanda/View/Home/Toplists/ToplistsReducer.swift @@ -2,8 +2,6 @@ // ToplistsReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/08. -// import ComposableArchitecture diff --git a/EhPanda/View/Home/Toplists/ToplistsView.swift b/EhPanda/View/Home/Toplists/ToplistsView.swift index ca63ee79..0f53cb03 100644 --- a/EhPanda/View/Home/Toplists/ToplistsView.swift +++ b/EhPanda/View/Home/Toplists/ToplistsView.swift @@ -2,8 +2,6 @@ // ToplistsView.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/08. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Home/Watched/WatchedReducer.swift b/EhPanda/View/Home/Watched/WatchedReducer.swift index 3f63d345..37413972 100644 --- a/EhPanda/View/Home/Watched/WatchedReducer.swift +++ b/EhPanda/View/Home/Watched/WatchedReducer.swift @@ -2,8 +2,6 @@ // WatchedReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/09. -// import ComposableArchitecture diff --git a/EhPanda/View/Home/Watched/WatchedView.swift b/EhPanda/View/Home/Watched/WatchedView.swift index 10e8946f..3a59da56 100644 --- a/EhPanda/View/Home/Watched/WatchedView.swift +++ b/EhPanda/View/Home/Watched/WatchedView.swift @@ -2,8 +2,6 @@ // WatchedView.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/09. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Migration/MigrationReducer.swift b/EhPanda/View/Migration/MigrationReducer.swift index 83c3bf6b..a8a8c54a 100644 --- a/EhPanda/View/Migration/MigrationReducer.swift +++ b/EhPanda/View/Migration/MigrationReducer.swift @@ -2,8 +2,6 @@ // MigrationReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/03. -// import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Migration/MigrationView.swift b/EhPanda/View/Migration/MigrationView.swift index f26f461f..071a19e9 100644 --- a/EhPanda/View/Migration/MigrationView.swift +++ b/EhPanda/View/Migration/MigrationView.swift @@ -2,8 +2,6 @@ // MigrationView.swift // EhPanda // -// Created by 荒木辰造 on R 4/02/03. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index 6371be89..81f78fa6 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -2,9 +2,6 @@ // ReadingReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/22. -// Refactored for improved maintainability and modularity by zackie on 2025-07-28. -// import SwiftUI import TTProgressHUD @@ -1030,4 +1027,4 @@ private struct PrefetchHelper { return nil } } -} \ No newline at end of file +} diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index 0b2d6da3..eeb3115d 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -2,9 +2,6 @@ // ReadingView.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/22. -// Refactored for improved maintainability by zackie on 2025-07-28. -// import SwiftUI import Kingfisher diff --git a/EhPanda/View/Reading/Support/AdvancedList.swift b/EhPanda/View/Reading/Support/AdvancedList.swift index c2aec933..a20c1779 100644 --- a/EhPanda/View/Reading/Support/AdvancedList.swift +++ b/EhPanda/View/Reading/Support/AdvancedList.swift @@ -2,9 +2,6 @@ // AdvancedList.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/30. -// Improved architecture by zackie on 2025-07-28. -// import SwiftUI import SwiftUIPager diff --git a/EhPanda/View/Reading/Support/ControlPanel.swift b/EhPanda/View/Reading/Support/ControlPanel.swift index c2850878..966b2517 100644 --- a/EhPanda/View/Reading/Support/ControlPanel.swift +++ b/EhPanda/View/Reading/Support/ControlPanel.swift @@ -2,8 +2,6 @@ // ControlPanel.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/30. -// import SwiftUI import Kingfisher diff --git a/EhPanda/View/Reading/Support/GestureCoordinator.swift b/EhPanda/View/Reading/Support/GestureCoordinator.swift index b43377c6..7680652c 100644 --- a/EhPanda/View/Reading/Support/GestureCoordinator.swift +++ b/EhPanda/View/Reading/Support/GestureCoordinator.swift @@ -2,8 +2,6 @@ // GestureCoordinator.swift // EhPanda // -// Created by zackie on 2025-07-28 for improved Reading view architecture -// import SwiftUI import SwiftUIPager @@ -366,4 +364,4 @@ extension View { gestureCoordinator.handleDragEnded(value: value) } } -} \ No newline at end of file +} diff --git a/EhPanda/View/Reading/Support/ImageStackView.swift b/EhPanda/View/Reading/Support/ImageStackView.swift index 97f0f7b3..e96b7f09 100644 --- a/EhPanda/View/Reading/Support/ImageStackView.swift +++ b/EhPanda/View/Reading/Support/ImageStackView.swift @@ -2,8 +2,6 @@ // ImageStackView.swift // EhPanda // -// Created by zackie on 2025-07-28 for improved Reading view architecture -// import SwiftUI import Kingfisher @@ -346,4 +344,4 @@ struct ImageStackView_Previews: PreviewProvider { .previewLayout(.sizeThatFits) .padding() } -} \ No newline at end of file +} diff --git a/EhPanda/View/Reading/Support/LiveTextView.swift b/EhPanda/View/Reading/Support/LiveTextView.swift index 13ea5b66..7179b2c1 100644 --- a/EhPanda/View/Reading/Support/LiveTextView.swift +++ b/EhPanda/View/Reading/Support/LiveTextView.swift @@ -2,8 +2,6 @@ // LiveTextView.swift // EhPanda // -// Created by xioxin on 2022/2/12. -// import SwiftUI diff --git a/EhPanda/View/Reading/Support/PageCoordinator.swift b/EhPanda/View/Reading/Support/PageCoordinator.swift index 883669f7..9181dc19 100644 --- a/EhPanda/View/Reading/Support/PageCoordinator.swift +++ b/EhPanda/View/Reading/Support/PageCoordinator.swift @@ -2,8 +2,6 @@ // PageCoordinator.swift // EhPanda // -// Created by zackie on 2025-07-28 for improved Reading view architecture -// import SwiftUI import Combine @@ -294,4 +292,4 @@ struct ImageStackConfig { self.isFirstAvailable = dualPageConfig.isFirstAvailable self.isSecondAvailable = dualPageConfig.isSecondAvailable } -} \ No newline at end of file +} diff --git a/EhPanda/View/Reading/Support/ReadingViewExtensions.swift b/EhPanda/View/Reading/Support/ReadingViewExtensions.swift index 6c0adcbf..08e8ab6b 100644 --- a/EhPanda/View/Reading/Support/ReadingViewExtensions.swift +++ b/EhPanda/View/Reading/Support/ReadingViewExtensions.swift @@ -2,8 +2,6 @@ // ReadingViewExtensions.swift // EhPanda // -// Created by zackie on 2025-07-28 for improved Reading view architecture -// import SwiftUI import SwiftUIPager @@ -442,4 +440,4 @@ extension ReadingReducer.Route { } return nil } -} \ No newline at end of file +} diff --git a/EhPanda/View/Reading/Support/ReadingViewModel.swift b/EhPanda/View/Reading/Support/ReadingViewModel.swift index fdfc5904..4e3e51be 100644 --- a/EhPanda/View/Reading/Support/ReadingViewModel.swift +++ b/EhPanda/View/Reading/Support/ReadingViewModel.swift @@ -2,8 +2,6 @@ // ReadingViewModel.swift // EhPanda // -// Created by zackie on 2025-07-28 for improved Reading view architecture -// import SwiftUI import Combine @@ -318,4 +316,4 @@ private extension CGPoint { } // MARK: - Import Vision Framework -import Vision \ No newline at end of file +import Vision diff --git a/EhPanda/View/Search/SearchReducer.swift b/EhPanda/View/Search/SearchReducer.swift index c0c1df19..79604aa6 100644 --- a/EhPanda/View/Search/SearchReducer.swift +++ b/EhPanda/View/Search/SearchReducer.swift @@ -2,8 +2,6 @@ // SearchReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/12. -// import ComposableArchitecture diff --git a/EhPanda/View/Search/SearchRootReducer.swift b/EhPanda/View/Search/SearchRootReducer.swift index 6444fc9b..c55a95fd 100644 --- a/EhPanda/View/Search/SearchRootReducer.swift +++ b/EhPanda/View/Search/SearchRootReducer.swift @@ -2,8 +2,6 @@ // SearchRootReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/09. -// import ComposableArchitecture diff --git a/EhPanda/View/Search/SearchRootView.swift b/EhPanda/View/Search/SearchRootView.swift index 3f701626..033346cd 100644 --- a/EhPanda/View/Search/SearchRootView.swift +++ b/EhPanda/View/Search/SearchRootView.swift @@ -2,8 +2,6 @@ // SearchRootView.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/09. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift index 0411efd3..0ba8c1d2 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -2,8 +2,6 @@ // SearchView.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/12. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Search/Support/QuickSearchReducer.swift b/EhPanda/View/Search/Support/QuickSearchReducer.swift index 56001f1f..1c48d371 100644 --- a/EhPanda/View/Search/Support/QuickSearchReducer.swift +++ b/EhPanda/View/Search/Support/QuickSearchReducer.swift @@ -2,8 +2,6 @@ // QuickSearchReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/20. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Search/Support/QuickSearchView.swift b/EhPanda/View/Search/Support/QuickSearchView.swift index caa9d993..c2ec2137 100644 --- a/EhPanda/View/Search/Support/QuickSearchView.swift +++ b/EhPanda/View/Search/Support/QuickSearchView.swift @@ -2,8 +2,6 @@ // QuickSearchView.swift // EhPanda // -// Created by 荒木辰造 on R 3/09/25. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift b/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift index f187c801..e36a84fc 100644 --- a/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift +++ b/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift @@ -2,8 +2,6 @@ // AccountSettingReducer.swift // EhPanda // -// Created by 荒木辰造 on R 3/12/31. -// import Foundation import TTProgressHUD diff --git a/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift b/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift index 0301d3c2..cce5ff1d 100644 --- a/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift +++ b/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift @@ -2,8 +2,6 @@ // AccountSettingView.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/12. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift index f4395078..d1b617c6 100644 --- a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift +++ b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift @@ -2,8 +2,6 @@ // AppearanceSettingReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/03. -// import ComposableArchitecture diff --git a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift index 5bd579d8..8b0053a9 100644 --- a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift +++ b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift @@ -2,8 +2,6 @@ // AppearanceSettingView.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/18. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/Components/AboutView.swift b/EhPanda/View/Setting/Components/AboutView.swift index e32633bd..8f5afcdf 100644 --- a/EhPanda/View/Setting/Components/AboutView.swift +++ b/EhPanda/View/Setting/Components/AboutView.swift @@ -2,8 +2,6 @@ // AboutView.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/18. -// import SwiftUI @@ -102,10 +100,6 @@ struct AboutView: View { // MARK: Code level contributors private let codeLevelContributors: [Info] = {[ - .init( - urlString: L10n.Constant.App.CodeLevelContributor.Link.tatsuz0u, - text: L10n.Constant.App.CodeLevelContributor.Text.tatsuz0u - ), .init( urlString: L10n.Constant.App.CodeLevelContributor.Link.chihchy, text: L10n.Constant.App.CodeLevelContributor.Text.chihchy @@ -126,10 +120,6 @@ struct AboutView: View { // MARK: Translation contributors private let translationContributors: [Info] = {[ - .init( - urlString: L10n.Constant.App.TranslationContributor.Link.tatsuz0u, - text: L10n.Constant.App.TranslationContributor.Text.tatsuz0u - ), .init( urlString: L10n.Constant.App.TranslationContributor.Link.nebulosaCat, text: L10n.Constant.App.TranslationContributor.Text.nebulosaCat diff --git a/EhPanda/View/Setting/Components/LaboratorySettingView.swift b/EhPanda/View/Setting/Components/LaboratorySettingView.swift index c2c284e7..ec0e4d81 100644 --- a/EhPanda/View/Setting/Components/LaboratorySettingView.swift +++ b/EhPanda/View/Setting/Components/LaboratorySettingView.swift @@ -2,8 +2,6 @@ // LaboratorySettingView.swift // LabSettingView // -// Created by 荒木辰造 on R 3/07/16. -// import SwiftUI import SFSafeSymbols diff --git a/EhPanda/View/Setting/Components/ReadingSettingView.swift b/EhPanda/View/Setting/Components/ReadingSettingView.swift index c1279be7..82b39f83 100644 --- a/EhPanda/View/Setting/Components/ReadingSettingView.swift +++ b/EhPanda/View/Setting/Components/ReadingSettingView.swift @@ -2,8 +2,6 @@ // ReadingSettingView.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/18. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/Components/WebView.swift b/EhPanda/View/Setting/Components/WebView.swift index 79e883ac..885152ca 100644 --- a/EhPanda/View/Setting/Components/WebView.swift +++ b/EhPanda/View/Setting/Components/WebView.swift @@ -2,8 +2,6 @@ // WebView.swift // EhPanda // -// Created by 荒木辰造 on R 2/12/27. -// import WebKit import SwiftUI diff --git a/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift b/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift index 92d7ab89..6c80f66d 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift @@ -2,8 +2,6 @@ // EhSettingReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/01. -// import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView.swift b/EhPanda/View/Setting/EhSetting/EhSettingView.swift index 0ae77475..9c6f4643 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingView.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView.swift @@ -2,8 +2,6 @@ // EhSettingView.swift // EhPanda // -// Created by 荒木辰造 on R 3/08/07. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift index c8254b56..d63026bd 100644 --- a/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift +++ b/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift @@ -2,8 +2,6 @@ // GeneralSettingReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/03. -// import Kingfisher import LocalAuthentication diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift index 43dbabff..aeec5944 100644 --- a/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift +++ b/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift @@ -2,8 +2,6 @@ // GeneralSettingView.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/18. -// import SwiftUI import FilePicker diff --git a/EhPanda/View/Setting/Login/LoginReducer.swift b/EhPanda/View/Setting/Login/LoginReducer.swift index 5d7db616..358cd9a2 100644 --- a/EhPanda/View/Setting/Login/LoginReducer.swift +++ b/EhPanda/View/Setting/Login/LoginReducer.swift @@ -2,8 +2,6 @@ // LoginReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/01. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/Login/LoginView.swift b/EhPanda/View/Setting/Login/LoginView.swift index 2b13c1c6..4b0c9e72 100644 --- a/EhPanda/View/Setting/Login/LoginView.swift +++ b/EhPanda/View/Setting/Login/LoginView.swift @@ -2,8 +2,6 @@ // LoginView.swift // EhPanda // -// Created by 荒木辰造 on R 3/08/12. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/Logs/LogsReducer.swift b/EhPanda/View/Setting/Logs/LogsReducer.swift index 906ec4d1..55df0a96 100644 --- a/EhPanda/View/Setting/Logs/LogsReducer.swift +++ b/EhPanda/View/Setting/Logs/LogsReducer.swift @@ -2,8 +2,6 @@ // LogsReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/03. -// import ComposableArchitecture diff --git a/EhPanda/View/Setting/Logs/LogsView.swift b/EhPanda/View/Setting/Logs/LogsView.swift index 4b2b8d8b..30afa5c1 100644 --- a/EhPanda/View/Setting/Logs/LogsView.swift +++ b/EhPanda/View/Setting/Logs/LogsView.swift @@ -2,8 +2,6 @@ // LogsView.swift // EhPanda // -// Created by 荒木辰造 on R 3/06/27. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Setting/SettingReducer.swift b/EhPanda/View/Setting/SettingReducer.swift index 91e6885b..34af255a 100644 --- a/EhPanda/View/Setting/SettingReducer.swift +++ b/EhPanda/View/Setting/SettingReducer.swift @@ -2,8 +2,6 @@ // SettingReducer.swift // EhPanda // -// Created by 荒木辰造 on R 3/12/31. -// import Foundation import ComposableArchitecture diff --git a/EhPanda/View/Setting/SettingView.swift b/EhPanda/View/Setting/SettingView.swift index 9a82573a..ccfcabc1 100644 --- a/EhPanda/View/Setting/SettingView.swift +++ b/EhPanda/View/Setting/SettingView.swift @@ -2,8 +2,6 @@ // SettingView.swift // EhPanda // -// Created by 荒木辰造 on R 2/12/27. -// import SwiftUI import SFSafeSymbols diff --git a/EhPanda/View/Support/Components/ActivityView.swift b/EhPanda/View/Support/Components/ActivityView.swift index 79a7e742..696ff646 100644 --- a/EhPanda/View/Support/Components/ActivityView.swift +++ b/EhPanda/View/Support/Components/ActivityView.swift @@ -2,8 +2,6 @@ // ActivityView.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/19. -// import SwiftUI diff --git a/EhPanda/View/Support/Components/AlertView.swift b/EhPanda/View/Support/Components/AlertView.swift index ae9d52ad..3bb57300 100644 --- a/EhPanda/View/Support/Components/AlertView.swift +++ b/EhPanda/View/Support/Components/AlertView.swift @@ -2,8 +2,6 @@ // AlertView.swift // EhPanda // -// Created by 荒木辰造 on R 2/12/27. -// import SwiftUI import SFSafeSymbols diff --git a/EhPanda/View/Support/Components/CategoryView.swift b/EhPanda/View/Support/Components/CategoryView.swift index 1c4df40a..aa70d331 100644 --- a/EhPanda/View/Support/Components/CategoryView.swift +++ b/EhPanda/View/Support/Components/CategoryView.swift @@ -2,8 +2,6 @@ // CategoryView.swift // EhPanda // -// Created by 荒木辰造 on R 3/08/02. -// import SwiftUI diff --git a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift index 0ab70303..5eb828b7 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryCardCell.swift @@ -2,8 +2,6 @@ // GalleryCardCell.swift // EhPanda // -// Created by 荒木辰造 on R 3/12/13. -// import SwiftUI import Colorful diff --git a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift index ecae286c..95948467 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryDetailCell.swift @@ -2,8 +2,6 @@ // GalleryDetailCell.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/16. -// import SwiftUI import Kingfisher diff --git a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift b/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift index f8b07585..96be0877 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryHistoryCell.swift @@ -2,8 +2,6 @@ // GalleryHistoryCell.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/09. -// import SwiftUI import Kingfisher diff --git a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift index b0e95618..ff688ce5 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryRankingCell.swift @@ -2,8 +2,6 @@ // GalleryRankingCell.swift // EhPanda // -// Created by 荒木辰造 on R 3/12/14. -// import SwiftUI import Kingfisher diff --git a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift index b91975b7..784ffab3 100644 --- a/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift +++ b/EhPanda/View/Support/Components/Cells/GalleryThumbnailCell.swift @@ -2,8 +2,6 @@ // GalleryThumbnailCell.swift // EhPanda // -// Created by 荒木辰造 on R 3/08/02. -// import SwiftUI import Kingfisher diff --git a/EhPanda/View/Support/Components/GenericList.swift b/EhPanda/View/Support/Components/GenericList.swift index 2b02c52b..3014dffe 100644 --- a/EhPanda/View/Support/Components/GenericList.swift +++ b/EhPanda/View/Support/Components/GenericList.swift @@ -2,8 +2,6 @@ // GenericList.swift // EhPanda // -// Created by 荒木辰造 on R 3/07/25. -// import SwiftUI import WaterfallGrid diff --git a/EhPanda/View/Support/Components/Placeholder.swift b/EhPanda/View/Support/Components/Placeholder.swift index 8bba279b..89fea30e 100644 --- a/EhPanda/View/Support/Components/Placeholder.swift +++ b/EhPanda/View/Support/Components/Placeholder.swift @@ -2,8 +2,6 @@ // Placeholder.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/16. -// import SwiftUI diff --git a/EhPanda/View/Support/Components/SettingTextField.swift b/EhPanda/View/Support/Components/SettingTextField.swift index 6793b339..84901207 100644 --- a/EhPanda/View/Support/Components/SettingTextField.swift +++ b/EhPanda/View/Support/Components/SettingTextField.swift @@ -2,8 +2,6 @@ // SettingTextField.swift // SettingTextField // -// Created by 荒木辰造 on R 3/08/07. -// import SwiftUI diff --git a/EhPanda/View/Support/Components/SubSection.swift b/EhPanda/View/Support/Components/SubSection.swift index 674046f9..4a9ec67c 100644 --- a/EhPanda/View/Support/Components/SubSection.swift +++ b/EhPanda/View/Support/Components/SubSection.swift @@ -2,8 +2,6 @@ // SubSection.swift // EhPanda // -// Created by 荒木辰造 on R 3/12/18. -// import SwiftUI diff --git a/EhPanda/View/Support/Components/TagCloudView.swift b/EhPanda/View/Support/Components/TagCloudView.swift index e2522bc2..ba0158ef 100644 --- a/EhPanda/View/Support/Components/TagCloudView.swift +++ b/EhPanda/View/Support/Components/TagCloudView.swift @@ -2,7 +2,6 @@ // TagCloudView.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/14. // Copied from https://stackoverflow.com/questions/62102647/ // diff --git a/EhPanda/View/Support/Components/TagSuggestionView.swift b/EhPanda/View/Support/Components/TagSuggestionView.swift index b567cf3f..1d1b1184 100644 --- a/EhPanda/View/Support/Components/TagSuggestionView.swift +++ b/EhPanda/View/Support/Components/TagSuggestionView.swift @@ -2,8 +2,6 @@ // TagSuggestionView.swift // EhPanda // -// Created by xioxin on 2022/2/15. -// import SwiftUI import Kingfisher diff --git a/EhPanda/View/Support/Components/ToolbarItems.swift b/EhPanda/View/Support/Components/ToolbarItems.swift index 727b4966..7139d358 100644 --- a/EhPanda/View/Support/Components/ToolbarItems.swift +++ b/EhPanda/View/Support/Components/ToolbarItems.swift @@ -2,8 +2,6 @@ // ToolbarItems.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/08. -// import SwiftUI diff --git a/EhPanda/View/Support/Components/WaveForm.swift b/EhPanda/View/Support/Components/WaveForm.swift index 179235f9..a61676ae 100644 --- a/EhPanda/View/Support/Components/WaveForm.swift +++ b/EhPanda/View/Support/Components/WaveForm.swift @@ -2,7 +2,6 @@ // WaveForm.swift // WaveForm // -// Created by 荒木辰造 on R 3/08/12. // Copied from Kavsoft // diff --git a/EhPanda/View/Support/FiltersReducer.swift b/EhPanda/View/Support/FiltersReducer.swift index 03bb1908..12a960f9 100644 --- a/EhPanda/View/Support/FiltersReducer.swift +++ b/EhPanda/View/Support/FiltersReducer.swift @@ -2,8 +2,6 @@ // FiltersReducer.swift // EhPanda // -// Created by 荒木辰造 on R 4/01/09. -// import ComposableArchitecture diff --git a/EhPanda/View/Support/FiltersView.swift b/EhPanda/View/Support/FiltersView.swift index 51562717..e7cdc780 100644 --- a/EhPanda/View/Support/FiltersView.swift +++ b/EhPanda/View/Support/FiltersView.swift @@ -2,8 +2,6 @@ // FiltersView.swift // EhPanda // -// Created by 荒木辰造 on R 3/01/08. -// import SwiftUI import ComposableArchitecture diff --git a/EhPanda/View/Support/NewDawnView.swift b/EhPanda/View/Support/NewDawnView.swift index eb03ed0d..00667b54 100644 --- a/EhPanda/View/Support/NewDawnView.swift +++ b/EhPanda/View/Support/NewDawnView.swift @@ -2,8 +2,6 @@ // NewDawnView.swift // EhPanda // -// Created by 荒木辰造 on R 3/05/05. -// import SwiftUI diff --git a/EhPanda/View/TabBar/TabBarReducer.swift b/EhPanda/View/TabBar/TabBarReducer.swift index d550073b..2fc33e65 100644 --- a/EhPanda/View/TabBar/TabBarReducer.swift +++ b/EhPanda/View/TabBar/TabBarReducer.swift @@ -2,8 +2,6 @@ // TabBarReducer.swift // EhPanda // -// Created by 荒木辰造 on R 3/12/29. -// import ComposableArchitecture diff --git a/EhPanda/View/TabBar/TabBarView.swift b/EhPanda/View/TabBar/TabBarView.swift index d224e990..ecaba9e5 100644 --- a/EhPanda/View/TabBar/TabBarView.swift +++ b/EhPanda/View/TabBar/TabBarView.swift @@ -2,8 +2,6 @@ // TabBarView.swift // EhPanda // -// Created by 荒木辰造 on R 3/12/29. -// import SwiftUI import SFSafeSymbols diff --git a/EhPandaTests/Models/HTMLFilename.swift b/EhPandaTests/Models/HTMLFilename.swift index 20635458..243eee1b 100644 --- a/EhPandaTests/Models/HTMLFilename.swift +++ b/EhPandaTests/Models/HTMLFilename.swift @@ -2,8 +2,6 @@ // HTMLFilename.swift // EhPandaTests // -// Created by 荒木辰造 on R 4/02/11. -// enum HTMLFilename: String { // List diff --git a/EhPandaTests/Models/ListParserTestType.swift b/EhPandaTests/Models/ListParserTestType.swift index f356e6d5..657e079a 100644 --- a/EhPandaTests/Models/ListParserTestType.swift +++ b/EhPandaTests/Models/ListParserTestType.swift @@ -2,8 +2,6 @@ // ListParserTestType.swift // EhPandaTests // -// Created by 荒木辰造 on R 4/02/11. -// enum ListParserTestType: CaseIterable { // FrontPage diff --git a/EhPandaTests/Models/TestError.swift b/EhPandaTests/Models/TestError.swift index 19c05fd8..71f33c6c 100644 --- a/EhPandaTests/Models/TestError.swift +++ b/EhPandaTests/Models/TestError.swift @@ -2,8 +2,6 @@ // TestError.swift // EhPandaTests // -// Created by 荒木辰造 on R 4/02/11. -// enum TestError: Error { case htmlDocumentNotFound(HTMLFilename) diff --git a/EhPandaTests/Resources/Utility/Extensions.swift b/EhPandaTests/Resources/Utility/Extensions.swift index 639326e2..1b21cf70 100644 --- a/EhPandaTests/Resources/Utility/Extensions.swift +++ b/EhPandaTests/Resources/Utility/Extensions.swift @@ -2,8 +2,6 @@ // Extensions.swift // EhPandaTests // -// Created by 荒木辰造 on R 4/03/26. -// import XCTest diff --git a/EhPandaTests/Resources/Utility/TestHelper.swift b/EhPandaTests/Resources/Utility/TestHelper.swift index b5488935..a26a72de 100644 --- a/EhPandaTests/Resources/Utility/TestHelper.swift +++ b/EhPandaTests/Resources/Utility/TestHelper.swift @@ -2,8 +2,6 @@ // TestHelper.swift // TestHelper // -// Created by 荒木辰造 on R 3/08/21. -// import Kanna import XCTest diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift index dbb029aa..50d83112 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift +++ b/EhPandaTests/Tests/Parser/Gallery/GalleryDetailParserTests.swift @@ -2,8 +2,6 @@ // GalleryDetailParserTests.swift // EhPandaTests // -// Created by 荒木辰造 on R 4/02/11. -// import Kanna import XCTest diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift index e7a3607d..6f4d2a12 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift +++ b/EhPandaTests/Tests/Parser/Gallery/GalleryImageURLParserTests.swift @@ -2,8 +2,6 @@ // GalleryImageURLParserTests.swift // EhPandaTests // -// Created by 荒木辰造 on R 4/02/11. -// import Kanna import XCTest diff --git a/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift b/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift index f288c2fd..215f1295 100644 --- a/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift +++ b/EhPandaTests/Tests/Parser/Gallery/GalleryMPVKeysParserTests.swift @@ -2,8 +2,6 @@ // GalleryMPVKeysParserTests.swift // EhPandaTests // -// Created by 荒木辰造 on R 4/02/11. -// import Kanna import XCTest diff --git a/EhPandaTests/Tests/Parser/List/ListParserTests.swift b/EhPandaTests/Tests/Parser/List/ListParserTests.swift index 94f2f4b6..c03e8a2c 100644 --- a/EhPandaTests/Tests/Parser/List/ListParserTests.swift +++ b/EhPandaTests/Tests/Parser/List/ListParserTests.swift @@ -2,8 +2,6 @@ // ListParserTests.swift // EhPandaTests // -// Created by 荒木辰造 on R 4/02/11. -// import Kanna import XCTest diff --git a/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift b/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift index 7f4ace78..5a4672ba 100644 --- a/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift +++ b/EhPandaTests/Tests/Parser/Other/BanIntervalParserTests.swift @@ -2,8 +2,6 @@ // BanIntervalParserTests.swift // EhPandaTests // -// Created by 荒木辰造 on R 4/02/11. -// import Kanna import XCTest diff --git a/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift b/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift index 072e1cd5..aa9c6775 100644 --- a/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift +++ b/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift @@ -2,8 +2,6 @@ // EhSettingParserTests.swift // EhPandaTests // -// Created by 荒木辰造 on R 4/02/15. -// import Kanna import XCTest diff --git a/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift b/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift index 5891775e..6da76019 100644 --- a/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift +++ b/EhPandaTests/Tests/Parser/Other/GreetingParserTests.swift @@ -2,8 +2,6 @@ // GreetingParserTests.swift // EhPandaTests // -// Created by 荒木辰造 on R 4/02/11. -// import Kanna import XCTest diff --git a/README.md b/README.md index 18faf6ba..5fb894eb 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,3 @@ The content in this application is derived from E-Hentai, which is user-generate ## Screenshots https://ehpanda.app - -## App Icon -Copyright © 2025 荒木辰造. All rights reserved. diff --git a/READMEs/README.chs.md b/READMEs/README.chs.md index daf44834..468d79cb 100644 --- a/READMEs/README.chs.md +++ b/READMEs/README.chs.md @@ -43,6 +43,3 @@ https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/b ## 截图 https://ehpanda.app - -## 应用程序图标 -Copyright © 2025 荒木辰造. All rights reserved. diff --git a/READMEs/README.cht.md b/READMEs/README.cht.md index 3f652e7f..53b3bb86 100644 --- a/READMEs/README.cht.md +++ b/READMEs/README.cht.md @@ -43,6 +43,3 @@ https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/b ## 螢幕截圖 https://ehpanda.app - -## 應用程式圖示 -Copyright © 2025 荒木辰造. All rights reserved. diff --git a/READMEs/README.de.md b/READMEs/README.de.md index bdb820d2..8c966c03 100644 --- a/READMEs/README.de.md +++ b/READMEs/README.de.md @@ -43,6 +43,3 @@ Der Inhalt der von dieser App verwaltet wird, wird von E-Hentai geladen. Hierbei ## Screenshots https://ehpanda.app - -## App Icon -Copyright © 2025 荒木辰造. All rights reserved. diff --git a/READMEs/README.jpn.md b/READMEs/README.jpn.md index a6449bc2..32da5083 100644 --- a/READMEs/README.jpn.md +++ b/READMEs/README.jpn.md @@ -43,6 +43,3 @@ iOS・iPadOS 17.0 以上が必要です。 ## スクリーンショット https://ehpanda.app - -## アプリアイコン -Copyright © 2025 荒木辰造. All rights reserved. diff --git a/READMEs/README.ko.md b/READMEs/README.ko.md index b7b2a6c3..b9b593fe 100644 --- a/READMEs/README.ko.md +++ b/READMEs/README.ko.md @@ -43,6 +43,3 @@ iOS / iPadOS 버전이 17.0 이상인지 확인해주세요. ## 화면캡쳐 https://ehpanda.app - -## 앱 아이콘 -Copyright © 2025 荒木辰造. All rights reserved. diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index 45eeb618..d9df2d99 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -2,8 +2,6 @@ // ShareViewController.swift // ShareExtension // -// Created by 荒木辰造 on R 3/08/03. -// import UIKit From b456b52ffc4422357fc36437b563cfca253de3f6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 13 Oct 2025 11:00:29 +0800 Subject: [PATCH 19/40] Resolve warnings --- EhPanda/App/Tools/ColorCodable.swift | 2 +- EhPanda/View/Reading/ReadingReducer.swift | 444 +++++++++--------- EhPanda/View/Reading/ReadingView.swift | 73 +-- .../View/Reading/Support/AdvancedList.swift | 59 ++- .../View/Reading/Support/ControlPanel.swift | 18 +- .../Reading/Support/GestureCoordinator.swift | 123 ++--- .../View/Reading/Support/ImageStackView.swift | 92 ++-- .../Reading/Support/PageCoordinator.swift | 102 ++-- .../Support/ReadingViewExtensions.swift | 67 +-- .../Reading/Support/ReadingViewModel.swift | 92 ++-- 10 files changed, 543 insertions(+), 529 deletions(-) diff --git a/EhPanda/App/Tools/ColorCodable.swift b/EhPanda/App/Tools/ColorCodable.swift index 01ec6843..4e50b0bc 100644 --- a/EhPanda/App/Tools/ColorCodable.swift +++ b/EhPanda/App/Tools/ColorCodable.swift @@ -44,7 +44,7 @@ private extension Color { } } -extension Color: Codable { +extension Color: @retroactive Codable { enum CodingKeys: String, CodingKey { case red, green, blue } diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index 81f78fa6..c64533a2 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -10,7 +10,7 @@ import ComposableArchitecture // MARK: - Reading Reducer @Reducer struct ReadingReducer { - + // MARK: - Route @CasePathable enum Route: Equatable { @@ -18,12 +18,12 @@ struct ReadingReducer { case share(IdentifiableBox) case readingSetting(EquatableVoid = .init()) } - + // MARK: - Share Item enum ShareItem: Equatable { case data(Data) case image(UIImage) - + var associatedValue: Any { switch self { case .data(let data): return data @@ -31,14 +31,14 @@ struct ReadingReducer { } } } - + // MARK: - Image Action enum ImageAction { case copy(Bool) case save(Bool) case share(Bool) } - + // MARK: - Cancel IDs private enum CancelID: CaseIterable { case fetchImage @@ -50,7 +50,7 @@ struct ReadingReducer { case fetchMPVKeys case fetchMPVImageURL } - + // MARK: - State @ObservableState struct State: Equatable { @@ -60,33 +60,33 @@ struct ReadingReducer { var showsSliderPreview = false var hudConfig: TTProgressHUDConfig = .loading var forceRefreshID: UUID = .init() - + // MARK: - Gallery Data var gallery: Gallery = .empty var galleryDetail: GalleryDetail? var readingProgress: Int = .zero - + // MARK: - Loading States var webImageLoadSuccessIndices = Set() var imageURLLoadingStates = [Int: LoadingState]() var previewLoadingStates = [Int: LoadingState]() var databaseLoadingState: LoadingState = .loading - + // MARK: - Preview Configuration var previewConfig: PreviewConfig = .normal(rows: 4) - + // MARK: - URL Storage var previewURLs = [Int: URL]() var thumbnailURLs = [Int: URL]() var imageURLs = [Int: URL]() var originalImageURLs = [Int: URL]() - + // MARK: - MPV Support var mpvKey: String? var mpvImageKeys = [Int: String]() var mpvSkipServerIdentifiers = [Int: String]() } - + // MARK: - Action enum Action: BindableAction { // MARK: - Binding & Navigation @@ -96,17 +96,17 @@ struct ReadingReducer { case onPerformDismiss case onAppear(String, Bool) case teardown - + // MARK: - Orientation case setOrientationPortrait(Bool) - + // MARK: - Web Image Actions case onWebImageRetry(Int) case onWebImageSucceeded(Int) case onWebImageFailed(Int) case reloadAllWebImages case retryAllFailedWebImages - + // MARK: - Image Actions case copyImage(URL) case saveImage(URL) @@ -114,43 +114,43 @@ struct ReadingReducer { case shareImage(URL) case fetchImage(ImageAction, URL) case fetchImageDone(ImageAction, Result) - + // MARK: - Data Synchronization case syncReadingProgress(Int) case syncPreviewURLs([Int: URL]) case syncThumbnailURLs([Int: URL]) case syncImageURLs([Int: URL], [Int: URL]) - + // MARK: - Database Operations case fetchDatabaseInfos(String) case fetchDatabaseInfosDone(GalleryState) - + // MARK: - Preview Operations case fetchPreviewURLs(Int) case fetchPreviewURLsDone(Int, Result<[Int: URL], AppError>) - + // MARK: - Image URL Operations case fetchImageURLs(Int) case refetchImageURLs(Int) case prefetchImages(Int, Int) - + // MARK: - Thumbnail Operations case fetchThumbnailURLs(Int) case fetchThumbnailURLsDone(Int, Result<[Int: URL], AppError>) - + // MARK: - Normal Image Operations case fetchNormalImageURLs(Int, [Int: URL]) case fetchNormalImageURLsDone(Int, Result<([Int: URL], [Int: URL]), AppError>) case refetchNormalImageURLs(Int) case refetchNormalImageURLsDone(Int, Result<([Int: URL], HTTPURLResponse?), AppError>) - + // MARK: - MPV Operations case fetchMPVKeys(Int, URL) case fetchMPVKeysDone(Int, Result<(String, [Int: String]), AppError>) case fetchMPVImageURL(Int, Bool) case fetchMPVImageURLDone(Int, Result<(URL, URL?, String), AppError>) } - + // MARK: - Dependencies @Dependency(\.appDelegateClient) private var appDelegateClient @Dependency(\.clipboardClient) private var clipboardClient @@ -160,149 +160,149 @@ struct ReadingReducer { @Dependency(\.deviceClient) private var deviceClient @Dependency(\.imageClient) private var imageClient @Dependency(\.urlClient) private var urlClient - + // MARK: - Body var body: some Reducer { BindingReducer() .onChange(of: \.showsSliderPreview) { _, _ in - Reduce({ _, _ in - .run(operation: { _ in - hapticsClient.generateFeedback(.soft) - }) + Reduce({ _, _ in + .run(operation: { _ in + hapticsClient.generateFeedback(.soft) + }) }) } - + Reduce { state, action in switch action { // MARK: - Basic Actions case .binding: return .none - + case .setNavigation(let route): return handleSetNavigation(&state, route: route) - + case .toggleShowsPanel: return handleToggleShowsPanel(&state) - + case .onPerformDismiss: return handlePerformDismiss() - + case .onAppear(let gid, let enablesLandscape): return handleOnAppear(&state, gid: gid, enablesLandscape: enablesLandscape) - + case .teardown: return handleTeardown(&state) - + // MARK: - Orientation Actions case .setOrientationPortrait(let isPortrait): return handleSetOrientationPortrait(isPortrait: isPortrait) - + // MARK: - Web Image Actions case .onWebImageRetry(let index): return handleWebImageRetry(&state, index: index) - + case .onWebImageSucceeded(let index): return handleWebImageSucceeded(&state, index: index) - + case .onWebImageFailed(let index): return handleWebImageFailed(&state, index: index) - + case .reloadAllWebImages: return handleReloadAllWebImages(&state) - + case .retryAllFailedWebImages: return handleRetryAllFailedWebImages(&state) - + // MARK: - Image Actions case .copyImage(let imageURL): return handleCopyImage(imageURL: imageURL) - + case .saveImage(let imageURL): return handleSaveImage(imageURL: imageURL) - + case .saveImageDone(let isSucceeded): return handleSaveImageDone(&state, isSucceeded: isSucceeded) - + case .shareImage(let imageURL): return handleShareImage(imageURL: imageURL) - + case .fetchImage(let action, let imageURL): return handleFetchImage(action: action, imageURL: imageURL) - + case .fetchImageDone(let action, let result): return handleFetchImageDone(&state, action: action, result: result) - + // MARK: - Synchronization Actions case .syncReadingProgress(let progress): return handleSyncReadingProgress(state: state, progress: progress) - + case .syncPreviewURLs(let previewURLs): return handleSyncPreviewURLs(state: state, previewURLs: previewURLs) - + case .syncThumbnailURLs(let thumbnailURLs): return handleSyncThumbnailURLs(state: state, thumbnailURLs: thumbnailURLs) - + case .syncImageURLs(let imageURLs, let originalImageURLs): return handleSyncImageURLs( - state: state, - imageURLs: imageURLs, + state: state, + imageURLs: imageURLs, originalImageURLs: originalImageURLs ) - + // MARK: - Database Actions case .fetchDatabaseInfos(let gid): return handleFetchDatabaseInfos(&state, gid: gid) - + case .fetchDatabaseInfosDone(let galleryState): return handleFetchDatabaseInfosDone(&state, galleryState: galleryState) - + // MARK: - Preview Actions case .fetchPreviewURLs(let index): return handleFetchPreviewURLs(&state, index: index) - + case .fetchPreviewURLsDone(let index, let result): return handleFetchPreviewURLsDone(&state, index: index, result: result) - + // MARK: - Image URL Actions case .fetchImageURLs(let index): return handleFetchImageURLs(&state, index: index) - + case .refetchImageURLs(let index): return handleRefetchImageURLs(&state, index: index) - + case .prefetchImages(let index, let prefetchLimit): return handlePrefetchImages(&state, index: index, prefetchLimit: prefetchLimit) - + // MARK: - Thumbnail Actions case .fetchThumbnailURLs(let index): return handleFetchThumbnailURLs(&state, index: index) - + case .fetchThumbnailURLsDone(let index, let result): return handleFetchThumbnailURLsDone(&state, index: index, result: result) - + // MARK: - Normal Image Actions case .fetchNormalImageURLs(let index, let thumbnailURLs): return handleFetchNormalImageURLs(index: index, thumbnailURLs: thumbnailURLs) - + case .fetchNormalImageURLsDone(let index, let result): return handleFetchNormalImageURLsDone(&state, index: index, result: result) - + case .refetchNormalImageURLs(let index): return handleRefetchNormalImageURLs(&state, index: index) - + case .refetchNormalImageURLsDone(let index, let result): return handleRefetchNormalImageURLsDone(&state, index: index, result: result) - + // MARK: - MPV Actions case .fetchMPVKeys(let index, let mpvURL): return handleFetchMPVKeys(index: index, mpvURL: mpvURL) - + case .fetchMPVKeysDone(let index, let result): return handleFetchMPVKeysDone(&state, index: index, result: result) - + case .fetchMPVImageURL(let index, let isRefresh): return handleFetchMPVImageURL(&state, index: index, isRefresh: isRefresh) - + case .fetchMPVImageURLDone(let index, let result): return handleFetchMPVImageURLDone(&state, index: index, result: result) } @@ -310,26 +310,26 @@ struct ReadingReducer { .haptics(unwrapping: \.route, case: \.readingSetting, hapticsClient: hapticsClient) .haptics(unwrapping: \.route, case: \.share, hapticsClient: hapticsClient) } - + // MARK: - Handler Methods - + /// Basic Action Handlers func handleSetNavigation(_ state: inout State, route: Route?) -> Effect { state.route = route return .none } - + func handleToggleShowsPanel(_ state: inout State) -> Effect { state.showsPanel.toggle() return .none } - + func handlePerformDismiss() -> Effect { - return .run(operation: { _ in - hapticsClient.generateFeedback(.light) + return .run(operation: { _ in + hapticsClient.generateFeedback(.light) }) } - + func handleOnAppear(_ state: inout State, gid: String, enablesLandscape: Bool) -> Effect { var effects: [Effect] = [ .send(.fetchDatabaseInfos(gid)) @@ -339,7 +339,7 @@ struct ReadingReducer { } return .merge(effects) } - + func handleTeardown(_ state: inout State) -> Effect { var effects: [Effect] = [ .merge(CancelID.allCases.map(Effect.cancel(id:))) @@ -349,42 +349,42 @@ struct ReadingReducer { } return .merge(effects) } - + /// Orientation Handlers func handleSetOrientationPortrait(isPortrait: Bool) -> Effect { var effects = [Effect]() if isPortrait { - effects.append(.run(operation: { _ in - appDelegateClient.setPortraitOrientationMask() + effects.append(.run(operation: { _ in + appDelegateClient.setPortraitOrientationMask() })) - effects.append(.run(operation: { _ in - await appDelegateClient.setPortraitOrientation() + effects.append(.run(operation: { _ in + await appDelegateClient.setPortraitOrientation() })) } else { - effects.append(.run(operation: { _ in - appDelegateClient.setAllOrientationMask() + effects.append(.run(operation: { _ in + appDelegateClient.setAllOrientationMask() })) } return .merge(effects) } - + /// Web Image Handlers func handleWebImageRetry(_ state: inout State, index: Int) -> Effect { state.imageURLLoadingStates[index] = .idle return .none } - + func handleWebImageSucceeded(_ state: inout State, index: Int) -> Effect { state.imageURLLoadingStates[index] = .idle state.webImageLoadSuccessIndices.insert(index) return .none } - + func handleWebImageFailed(_ state: inout State, index: Int) -> Effect { state.imageURLLoadingStates[index] = .failed(.webImageFailed) return .none } - + func handleReloadAllWebImages(_ state: inout State) -> Effect { state.previewURLs = .init() state.thumbnailURLs = .init() @@ -394,12 +394,12 @@ struct ReadingReducer { state.mpvImageKeys = .init() state.mpvSkipServerIdentifiers = .init() state.forceRefreshID = .init() - + return .run { [galleryId = state.gallery.id] _ in await databaseClient.removeImageURLs(gid: galleryId) } } - + func handleRetryAllFailedWebImages(_ state: inout State) -> Effect { state.imageURLLoadingStates.forEach { (index, loadingState) in if case .failed = loadingState { @@ -413,25 +413,25 @@ struct ReadingReducer { } return .none } - + /// Image Action Handlers func handleCopyImage(imageURL: URL) -> Effect { return .send(.fetchImage(.copy(imageURL.isGIF), imageURL)) } - + func handleSaveImage(imageURL: URL) -> Effect { return .send(.fetchImage(.save(imageURL.isGIF), imageURL)) } - + func handleSaveImageDone(_ state: inout State, isSucceeded: Bool) -> Effect { state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error return .send(.setNavigation(.hud)) } - + func handleShareImage(imageURL: URL) -> Effect { return .send(.fetchImage(.share(imageURL.isGIF), imageURL)) } - + func handleFetchImage(action: ImageAction, imageURL: URL) -> Effect { return .run { send in let result = await imageClient.fetchImage(url: imageURL) @@ -439,10 +439,10 @@ struct ReadingReducer { } .cancellable(id: CancelID.fetchImage) } - + func handleFetchImageDone( - _ state: inout State, - action: ImageAction, + _ state: inout State, + action: ImageAction, result: Result ) -> Effect { switch result { @@ -453,10 +453,10 @@ struct ReadingReducer { return .send(.setNavigation(.hud)) } } - + private func handleSuccessfulImageFetch( - state: inout State, - action: ImageAction, + state: inout State, + action: ImageAction, image: UIImage ) -> Effect { switch action { @@ -464,8 +464,8 @@ struct ReadingReducer { state.hudConfig = .copiedToClipboardSucceeded return .merge( .send(.setNavigation(.hud)), - .run(operation: { _ in - clipboardClient.saveImage(image, isAnimated) + .run(operation: { _ in + clipboardClient.saveImage(image, isAnimated) }) ) case .save(let isAnimated): @@ -481,38 +481,38 @@ struct ReadingReducer { } } } - + /// Synchronization Handlers func handleSyncReadingProgress(state: State, progress: Int) -> Effect { return .run { _ in await databaseClient.updateReadingProgress( - gid: state.gallery.id, + gid: state.gallery.id, progress: progress ) } } - + func handleSyncPreviewURLs(state: State, previewURLs: [Int: URL]) -> Effect { return .run { _ in await databaseClient.updatePreviewURLs( - gid: state.gallery.id, + gid: state.gallery.id, previewURLs: previewURLs ) } } - + func handleSyncThumbnailURLs(state: State, thumbnailURLs: [Int: URL]) -> Effect { return .run { _ in await databaseClient.updateThumbnailURLs( - gid: state.gallery.id, + gid: state.gallery.id, thumbnailURLs: thumbnailURLs ) } } - + func handleSyncImageURLs( - state: State, - imageURLs: [Int: URL], + state: State, + imageURLs: [Int: URL], originalImageURLs: [Int: URL] ) -> Effect { return .run { _ in @@ -523,27 +523,27 @@ struct ReadingReducer { ) } } - + /// Database Handlers func handleFetchDatabaseInfos(_ state: inout State, gid: String) -> Effect { - guard let gallery = databaseClient.fetchGallery(gid: gid) else { - return .none + guard let gallery = databaseClient.fetchGallery(gid: gid) else { + return .none } - + state.gallery = gallery state.galleryDetail = databaseClient.fetchGalleryDetail(gid: state.gallery.id) - + return .run { [galleryId = state.gallery.id] send in - guard let dbState = await databaseClient.fetchGalleryState(gid: galleryId) else { - return + guard let dbState = await databaseClient.fetchGalleryState(gid: galleryId) else { + return } await send(.fetchDatabaseInfosDone(dbState)) } .cancellable(id: CancelID.fetchDatabaseInfos) } - + func handleFetchDatabaseInfosDone( - _ state: inout State, + _ state: inout State, galleryState: GalleryState ) -> Effect { if let previewConfig = galleryState.previewConfig { @@ -557,31 +557,31 @@ struct ReadingReducer { state.databaseLoadingState = .idle return .none } - + /// Preview Handlers func handleFetchPreviewURLs(_ state: inout State, index: Int) -> Effect { guard state.previewLoadingStates[index] != .loading, let galleryURL = state.gallery.galleryURL - else { - return .none + else { + return .none } - + state.previewLoadingStates[index] = .loading let pageNum = state.previewConfig.pageNumber(index: index) - + return .run { send in let response = await GalleryPreviewURLsRequest( - galleryURL: galleryURL, + galleryURL: galleryURL, pageNum: pageNum ).response() await send(.fetchPreviewURLsDone(index, response)) } .cancellable(id: CancelID.fetchPreviewURLs) } - + func handleFetchPreviewURLsDone( - _ state: inout State, - index: Int, + _ state: inout State, + index: Int, result: Result<[Int: URL], AppError> ) -> Effect { switch result { @@ -598,7 +598,7 @@ struct ReadingReducer { return .none } } - + /// Image URL Handlers func handleFetchImageURLs(_ state: inout State, index: Int) -> Effect { if state.mpvKey != nil { @@ -607,7 +607,7 @@ struct ReadingReducer { return .send(.fetchThumbnailURLs(index)) } } - + func handleRefetchImageURLs(_ state: inout State, index: Int) -> Effect { if state.mpvKey != nil { return .send(.fetchMPVImageURL(index, true)) @@ -615,50 +615,50 @@ struct ReadingReducer { return .send(.refetchNormalImageURLs(index)) } } - + func handlePrefetchImages( - _ state: inout State, - index: Int, + _ state: inout State, + index: Int, prefetchLimit: Int ) -> Effect { let prefetchHelper = PrefetchHelper(state: state, imageClient: imageClient) return prefetchHelper.createPrefetchEffects( - currentIndex: index, + currentIndex: index, prefetchLimit: prefetchLimit ) } - + /// Thumbnail Handlers func handleFetchThumbnailURLs(_ state: inout State, index: Int) -> Effect { guard state.imageURLLoadingStates[index] != .loading, let galleryURL = state.gallery.galleryURL - else { - return .none + else { + return .none } - + state.previewConfig.batchRange(index: index).forEach { state.imageURLLoadingStates[$0] = .loading } - + let pageNum = state.previewConfig.pageNumber(index: index) - + return .run { send in let response = await ThumbnailURLsRequest( - galleryURL: galleryURL, + galleryURL: galleryURL, pageNum: pageNum ).response() await send(.fetchThumbnailURLsDone(index, response)) } .cancellable(id: CancelID.fetchThumbnailURLs) } - + func handleFetchThumbnailURLsDone( - _ state: inout State, - index: Int, + _ state: inout State, + index: Int, result: Result<[Int: URL], AppError> ) -> Effect { let batchRange = state.previewConfig.batchRange(index: index) - + switch result { case .success(let thumbnailURLs): guard !thumbnailURLs.isEmpty else { @@ -667,7 +667,7 @@ struct ReadingReducer { } return .none } - + if let url = thumbnailURLs[index], urlClient.checkIfMPVURL(url) { return .send(.fetchMPVKeys(index, url)) } else { @@ -684,10 +684,10 @@ struct ReadingReducer { return .none } } - + /// Normal Image Handlers func handleFetchNormalImageURLs( - index: Int, + index: Int, thumbnailURLs: [Int: URL] ) -> Effect { return .run { send in @@ -698,14 +698,14 @@ struct ReadingReducer { } .cancellable(id: CancelID.fetchNormalImageURLs) } - + func handleFetchNormalImageURLsDone( - _ state: inout State, - index: Int, + _ state: inout State, + index: Int, result: Result<([Int: URL], [Int: URL]), AppError> ) -> Effect { let batchRange = state.previewConfig.batchRange(index: index) - + switch result { case .success(let (imageURLs, originalImageURLs)): guard !imageURLs.isEmpty else { @@ -714,13 +714,13 @@ struct ReadingReducer { } return .none } - + batchRange.forEach { state.imageURLLoadingStates[$0] = .idle } state.updateImageURLs(imageURLs, originalImageURLs) return .send(.syncImageURLs(imageURLs, originalImageURLs)) - + case .failure(let error): batchRange.forEach { state.imageURLLoadingStates[$0] = .failed(error) @@ -728,18 +728,18 @@ struct ReadingReducer { return .none } } - + func handleRefetchNormalImageURLs(_ state: inout State, index: Int) -> Effect { guard state.imageURLLoadingStates[index] != .loading, let galleryURL = state.gallery.galleryURL, let imageURL = state.imageURLs[index] - else { - return .none + else { + return .none } - + state.imageURLLoadingStates[index] = .loading let pageNum = state.previewConfig.pageNumber(index: index) - + return .run { [thumbnailURL = state.thumbnailURLs[index]] send in let response = await GalleryNormalImageURLRefetchRequest( index: index, @@ -752,38 +752,38 @@ struct ReadingReducer { } .cancellable(id: CancelID.refetchNormalImageURLs) } - + func handleRefetchNormalImageURLsDone( - _ state: inout State, - index: Int, + _ state: inout State, + index: Int, result: Result<([Int: URL], HTTPURLResponse?), AppError> ) -> Effect { switch result { case .success(let (imageURLs, response)): var effects = [Effect]() - + if let response = response { - effects.append(.run(operation: { _ in - cookieClient.setSkipServer(response: response) + effects.append(.run(operation: { _ in + cookieClient.setSkipServer(response: response) })) } - + guard !imageURLs.isEmpty else { state.imageURLLoadingStates[index] = .failed(.notFound) return effects.isEmpty ? .none : .merge(effects) } - + state.imageURLLoadingStates[index] = .idle state.updateImageURLs(imageURLs, [:]) effects.append(.send(.syncImageURLs(imageURLs, [:]))) return .merge(effects) - + case .failure(let error): state.imageURLLoadingStates[index] = .failed(error) return .none } } - + /// MPV Handlers func handleFetchMPVKeys(index: Int, mpvURL: URL) -> Effect { return .run { send in @@ -792,14 +792,14 @@ struct ReadingReducer { } .cancellable(id: CancelID.fetchMPVKeys) } - + func handleFetchMPVKeysDone( - _ state: inout State, - index: Int, + _ state: inout State, + index: Int, result: Result<(String, [Int: String]), AppError> ) -> Effect { let batchRange = state.previewConfig.batchRange(index: index) - + switch result { case .success(let (mpvKey, mpvImageKeys)): let pageCount = state.gallery.pageCount @@ -809,19 +809,19 @@ struct ReadingReducer { } return .none } - + batchRange.forEach { state.imageURLLoadingStates[$0] = .idle } state.mpvKey = mpvKey state.mpvImageKeys = mpvImageKeys - + return .merge( Array(1...min(3, max(1, pageCount))).map { .send(.fetchMPVImageURL($0, false)) } ) - + case .failure(let error): batchRange.forEach { state.imageURLLoadingStates[$0] = .failed(error) @@ -829,23 +829,23 @@ struct ReadingReducer { return .none } } - + func handleFetchMPVImageURL( - _ state: inout State, - index: Int, + _ state: inout State, + index: Int, isRefresh: Bool ) -> Effect { - guard let gidInteger = Int(state.gallery.id), + guard let gidInteger = Int(state.gallery.id), let mpvKey = state.mpvKey, let mpvImageKey = state.mpvImageKeys[index], state.imageURLLoadingStates[index] != .loading - else { - return .none + else { + return .none } - + state.imageURLLoadingStates[index] = .loading let skipServerIdentifier = isRefresh ? state.mpvSkipServerIdentifiers[index] : nil - + return .run { send in let response = await GalleryMPVImageURLRequest( gid: gidInteger, @@ -858,10 +858,10 @@ struct ReadingReducer { } .cancellable(id: CancelID.fetchMPVImageURL) } - + func handleFetchMPVImageURLDone( - _ state: inout State, - index: Int, + _ state: inout State, + index: Int, result: Result<(URL, URL?, String), AppError> ) -> Effect { switch result { @@ -871,12 +871,12 @@ struct ReadingReducer { if let originalImageURL = originalImageURL { originalImageURLs[index] = originalImageURL } - + state.imageURLLoadingStates[index] = .idle state.mpvSkipServerIdentifiers[index] = skipServerIdentifier state.updateImageURLs(imageURLs, originalImageURLs) return .send(.syncImageURLs(imageURLs, originalImageURLs)) - + case .failure(let error): state.imageURLLoadingStates[index] = .failed(error) return .none @@ -891,13 +891,13 @@ extension ReadingReducer.State { guard !previewURLs.isEmpty else { return } self.previewURLs = self.previewURLs.merging(previewURLs) { _, new in new } } - + /// Updates thumbnail URLs mutating func updateThumbnailURLs(_ thumbnailURLs: [Int: URL]) { guard !thumbnailURLs.isEmpty else { return } self.thumbnailURLs = self.thumbnailURLs.merging(thumbnailURLs) { _, new in new } } - + /// Updates image URLs and original image URLs mutating func updateImageURLs(_ imageURLs: [Int: URL], _ originalImageURLs: [Int: URL]) { if !imageURLs.isEmpty { @@ -907,29 +907,29 @@ extension ReadingReducer.State { self.originalImageURLs = self.originalImageURLs.merging(originalImageURLs) { _, new in new } } } - + /// Gets container data source for the current configuration func containerDataSource(setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> [Int] { let defaultData = Array(1...gallery.pageCount) - - guard isLandscape && - setting.enablesDualPageMode && - setting.readingDirection != .vertical - else { - return defaultData + + guard isLandscape && + setting.enablesDualPageMode && + setting.readingDirection != .vertical + else { + return defaultData } - + let data = setting.exceptCover ? [1] + Array(stride(from: 2, through: gallery.pageCount, by: 2)) : Array(stride(from: 1, through: gallery.pageCount, by: 2)) - + return data } - + /// Gets image container configurations for dual page mode func imageContainerConfigs( - index: Int, - setting: Setting, + index: Int, + setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape ) -> ImageStackConfig { let direction = setting.readingDirection @@ -937,15 +937,15 @@ extension ReadingReducer.State { let isFirstSingle = setting.exceptCover let isFirstPageAndSingle = index == 1 && isFirstSingle let isDualPage = isLandscape && setting.enablesDualPageMode && direction != .vertical - + let firstIndex = isDualPage && isReversed && !isFirstPageAndSingle ? index + 1 : index let secondIndex = firstIndex + (isReversed ? -1 : 1) - + let isValidFirstRange = firstIndex >= 1 && firstIndex <= gallery.pageCount let isValidSecondRange = isFirstSingle ? secondIndex >= 2 && secondIndex <= gallery.pageCount : secondIndex >= 1 && secondIndex <= gallery.pageCount - + let dualPageConfig = DualPageConfiguration( firstIndex: firstIndex, secondIndex: secondIndex, @@ -953,7 +953,7 @@ extension ReadingReducer.State { isSecondAvailable: !isFirstPageAndSingle && isValidSecondRange && isDualPage, isDualPage: isDualPage ) - + return ImageStackConfig(from: dualPageConfig) } } @@ -964,33 +964,33 @@ extension ReadingReducer.State { private struct PrefetchHelper { let state: ReadingReducer.State let imageClient: ImageClient - + func createPrefetchEffects(currentIndex: Int, prefetchLimit: Int) -> Effect { let (prefetchURLs, fetchIndices) = calculatePrefetchData( - currentIndex: currentIndex, + currentIndex: currentIndex, prefetchLimit: prefetchLimit ) - + var effects = fetchIndices.map { index in Effect.send(.fetchImageURLs(index)) } - + effects.append( .run { _ in imageClient.prefetchImages(prefetchURLs) } ) - + return .merge(effects) } - + private func calculatePrefetchData( - currentIndex: Int, + currentIndex: Int, prefetchLimit: Int ) -> (urls: [URL], indices: [Int]) { var prefetchURLs = [URL]() var fetchIndices = [Int]() - + // Previous pages let previousUpperBound = max(currentIndex - 2, 1) let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) @@ -999,7 +999,7 @@ private struct PrefetchHelper { prefetchURLs += getURLsForRange(previousRange) fetchIndices += getIndicesNeedingFetch(previousRange) } - + // Next pages let nextLowerBound = min(currentIndex + 2, state.gallery.pageCount) let nextUpperBound = min(nextLowerBound + prefetchLimit / 2, state.gallery.pageCount) @@ -1008,19 +1008,19 @@ private struct PrefetchHelper { prefetchURLs += getURLsForRange(nextRange) fetchIndices += getIndicesNeedingFetch(nextRange) } - + return (prefetchURLs, fetchIndices) } - + private func getURLsForRange(_ range: ClosedRange) -> [URL] { return range.compactMap { index in state.imageURLs[index] } } - + private func getIndicesNeedingFetch(_ range: ClosedRange) -> [Int] { return range.compactMap { index in - if state.imageURLs[index] == nil && + if state.imageURLs[index] == nil && state.imageURLLoadingStates[index] != .loading { return index } diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index eeb3115d..810a4161 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -12,7 +12,7 @@ import ComposableArchitecture struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme @Bindable var store: StoreOf - + // MARK: - Configuration private let gid: String @Binding private var setting: Setting @@ -35,13 +35,13 @@ struct ReadingView: View { self.gid = gid _setting = setting self.blurRadius = blurRadius - + // Initialize view models with dependencies _viewModel = StateObject(wrappedValue: ReadingViewModel()) _gestureCoordinator = StateObject(wrappedValue: GestureCoordinator()) _pageCoordinator = StateObject(wrappedValue: PageCoordinator()) } - + // MARK: - Body var body: some View { ZStack { @@ -55,7 +55,7 @@ struct ReadingView: View { pageCoordinator: pageCoordinator, page: page ) - + ReadingControlsOverlay( store: store, setting: $setting, @@ -85,17 +85,17 @@ struct ReadingView: View { page: page ) } - + // MARK: - Computed Properties private var backgroundColor: Color { colorScheme == .light ? Color(.systemGray4) : Color(.systemGray6) } - + // MARK: - Helper Methods private func setupViewModels() { viewModel.setup(with: store.state, setting: setting) gestureCoordinator.setup(setting: setting) - + // Setup page coordinator with initial reading progress if available if store.readingProgress > 0 { pageCoordinator.setup( @@ -103,7 +103,7 @@ struct ReadingView: View { setting: setting, initialPage: store.readingProgress ) - + // Also update the pager to the correct initial position let pagerIndex = pageCoordinator.mapToPager(index: store.readingProgress, setting: setting) page.update(.new(index: pagerIndex)) @@ -114,7 +114,7 @@ struct ReadingView: View { ) } } - + private func cleanup() { viewModel.cleanup() gestureCoordinator.cleanup() @@ -130,7 +130,7 @@ private struct ReadingContentView: View { @ObservedObject var gestureCoordinator: GestureCoordinator @ObservedObject var pageCoordinator: PageCoordinator let page: Page - + var body: some View { Group { if setting.readingDirection == .vertical { @@ -170,7 +170,7 @@ private struct VerticalReadingView: View { @ObservedObject var gestureCoordinator: GestureCoordinator @ObservedObject var pageCoordinator: PageCoordinator let page: Page - + var body: some View { // Fixed vertical scroll implementation for iOS 26 compatibility ImprovedScrollView( @@ -181,16 +181,17 @@ private struct VerticalReadingView: View { gestureCoordinator: gestureCoordinator, pageCoordinator: pageCoordinator, setting: setting, - onTogglePanel: { store.send(.toggleShowsPanel) } - ) { index in - ImageStackView( - index: index, - store: store, - setting: $setting, - viewModel: viewModel, - gestureCoordinator: gestureCoordinator - ) - } + onTogglePanel: { store.send(.toggleShowsPanel) }, + content: { index in + ImageStackView( + index: index, + store: store, + setting: $setting, + viewModel: viewModel, + gestureCoordinator: gestureCoordinator + ) + } + ) } } @@ -242,7 +243,7 @@ private struct ImprovedScrollView: View { let setting: Setting let onTogglePanel: () -> Void let content: (Int) -> Content - + @State private var performingChanges = false @State private var scrollTarget: Int? @State private var currentVisibleIndex: Int = 0 @@ -323,26 +324,26 @@ private struct ImprovedScrollView: View { } } } - + private func updateCurrentVisibleIndex(from preferences: [Int: ScrollOffsetData]) { guard !performingChanges else { return } - + // Find the most visible item (closest to center of screen) let screenCenter = UIScreen.main.bounds.height / 2 var mostVisibleIndex = 0 var maxVisibility: CGFloat = 0 - + for (_, item) in preferences { let itemCenter = item.frame.midY let distanceFromCenter = abs(itemCenter - screenCenter) let visibility = max(0, 1 - distanceFromCenter / screenCenter) - + if visibility > maxVisibility { maxVisibility = visibility mostVisibleIndex = item.index } } - + // Update page index if it changed significantly if mostVisibleIndex != currentVisibleIndex && maxVisibility > 0.5 { currentVisibleIndex = mostVisibleIndex @@ -350,12 +351,12 @@ private struct ImprovedScrollView: View { if page.index != newPageIndex { performingChanges = true page.update(.new(index: newPageIndex)) - + // Reset performing changes after a delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { performingChanges = false } - + Logger.info("Updated page index from scroll", context: [ "newPageIndex": newPageIndex, "visibility": maxVisibility @@ -363,7 +364,7 @@ private struct ImprovedScrollView: View { } } } - + private func handleTap(index: Int) { performingChanges = true page.update(.new(index: index - 1)) @@ -371,17 +372,17 @@ private struct ImprovedScrollView: View { performingChanges = false } } - + private func scrollToCurrentPage(proxy: ScrollViewProxy) { let targetId = page.index + 1 DispatchQueue.main.async { proxy.scrollTo(targetId, anchor: .center) } } - + private func scrollToPage(_ pageIndex: Int, proxy: ScrollViewProxy) { guard !performingChanges else { return } - + let targetId = pageIndex + 1 if isScrollEnabled { withAnimation(.easeInOut(duration: 0.3)) { @@ -402,7 +403,7 @@ private struct ScrollOffsetData: Equatable { private struct ScrollOffsetPreferenceKey: PreferenceKey { static var defaultValue: [Int: ScrollOffsetData] = [:] - + static func reduce(value: inout [Int: ScrollOffsetData], nextValue: () -> [Int: ScrollOffsetData]) { value.merge(nextValue()) { _, new in new } } @@ -416,7 +417,7 @@ private struct ReadingControlsOverlay: View { @ObservedObject var pageCoordinator: PageCoordinator @ObservedObject var gestureCoordinator: GestureCoordinator let page: Page - + var body: some View { ReadingControlPanel( showsPanel: Binding( @@ -446,7 +447,7 @@ private struct ReadingControlsOverlay: View { fetchPreviewURLsAction: { store.send(.fetchPreviewURLs($0)) } ) } - + private func createDismissGesture() -> some Gesture { DragGesture() .onEnded { value in diff --git a/EhPanda/View/Reading/Support/AdvancedList.swift b/EhPanda/View/Reading/Support/AdvancedList.swift index a20c1779..10ec2890 100644 --- a/EhPanda/View/Reading/Support/AdvancedList.swift +++ b/EhPanda/View/Reading/Support/AdvancedList.swift @@ -9,11 +9,11 @@ import SwiftUIPager /// Improved vertical list for reading view with iOS 26 scrolling fix struct AdvancedList: View where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { - + // MARK: - State @State private var performingChanges = false @State private var scrollTarget: Element? - + // MARK: - Properties private let pagerModel: Page private let data: [Element] @@ -21,13 +21,13 @@ where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { private let spacing: CGFloat private let gesture: G private let content: (Element) -> PageView - + // MARK: - Initialization init( - page: Page, + page: Page, data: Data, - id: KeyPath, - spacing: CGFloat, + id: KeyPath, + spacing: CGFloat, gesture: G, @ViewBuilder content: @escaping (Element) -> PageView ) where Data.Index == Int, Data.Element == Element { @@ -38,7 +38,7 @@ where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { self.gesture = gesture self.content = content } - + // MARK: - Body var body: some View { ScrollViewReader { proxy in @@ -65,17 +65,17 @@ where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { } } } - + // MARK: - Content with Gestures @ViewBuilder private func contentWithGestures(for element: Element) -> some View { let longPress = createLongPressGesture(for: element) let combinedGestures = longPress.simultaneously(with: gesture) - + content(element) .gesture(combinedGestures) } - + // MARK: - Gesture Creation private func createLongPressGesture(for element: Element) -> some Gesture { LongPressGesture(minimumDuration: 0, maximumDistance: .infinity) @@ -83,62 +83,62 @@ where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { handleLongPress(for: element) } } - + // MARK: - Event Handlers private func handleLongPress(for element: Element) { guard let index = element as? Int else { return } - + Logger.info("Long press detected", context: ["element": index]) - + performingChanges = true pagerModel.update(.new(index: index - 1)) - + // Reset performing changes after a delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { performingChanges = false } } - + private func initialScrollToPage(proxy: ScrollViewProxy) { guard !data.isEmpty else { return } - + let targetElement = getElementForPageIndex(pagerModel.index) scrollToElementSafely(targetElement, proxy: proxy, animated: false) } - + private func handlePageChange(newValue: Int, proxy: ScrollViewProxy) { guard !performingChanges else { return } - + Logger.info("Page changed in AdvancedList", context: [ "newPageIndex": newValue, "dataCount": data.count ]) - + let targetElement = getElementForPageIndex(newValue) scrollToElementSafely(targetElement, proxy: proxy, animated: true) } - + private func scrollToTarget(_ target: Element, proxy: ScrollViewProxy) { scrollToElementSafely(target, proxy: proxy, animated: true) scrollTarget = nil } - + // MARK: - Helper Methods private func getElementForPageIndex(_ pageIndex: Int) -> Element? { let safeIndex = max(0, min(pageIndex, data.count - 1)) guard safeIndex < data.count else { return nil } return data[safeIndex] } - + private func scrollToElementSafely( - _ element: Element?, - proxy: ScrollViewProxy, + _ element: Element?, + proxy: ScrollViewProxy, animated: Bool ) { guard let element = element else { return } - + let elementId = element[keyPath: id] - + if animated { withAnimation(.easeInOut(duration: 0.3)) { proxy.scrollTo(elementId, anchor: .center) @@ -149,7 +149,7 @@ where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { proxy.scrollTo(elementId, anchor: .center) } } - + Logger.info("Scrolled to element", context: [ "elementId": "\(elementId)", "animated": animated @@ -166,7 +166,7 @@ extension AdvancedList { .scrollContentBackground(.hidden) .scrollIndicators(.hidden) } - + /// Handles scroll position restoration for iOS 26 func withScrollRestoration() -> some View { self @@ -186,7 +186,7 @@ struct AdvancedList_Previews: PreviewProvider { static var previews: some View { let page = Page.first() let sampleData = Array(1...10) - + AdvancedList( page: page, data: sampleData, @@ -206,4 +206,3 @@ struct AdvancedList_Previews: PreviewProvider { .previewLayout(.sizeThatFits) } } - diff --git a/EhPanda/View/Reading/Support/ControlPanel.swift b/EhPanda/View/Reading/Support/ControlPanel.swift index 966b2517..e89323cf 100644 --- a/EhPanda/View/Reading/Support/ControlPanel.swift +++ b/EhPanda/View/Reading/Support/ControlPanel.swift @@ -139,9 +139,9 @@ private struct UpperPanel: View { } .padding(.leading, 20) } - + Spacer() - + // Page Number Display in Liquid Glass Bubble if #available(iOS 26.0, *) { Text(title) @@ -159,9 +159,9 @@ private struct UpperPanel: View { .background(Material.thinMaterial) .clipShape(RoundedRectangle(cornerRadius: 16)) } - + Spacer() - + // Toolbar Grouped in Liquid Glass Container if #available(iOS 26.0, *) { HStack(spacing: 16) { @@ -173,7 +173,7 @@ private struct UpperPanel: View { .symbolVariant(enablesLiveText ? .fill : .none) .font(.title2) } - + if DeviceUtil.isLandscape && setting.readingDirection != .vertical { Menu { Button { @@ -199,7 +199,7 @@ private struct UpperPanel: View { .font(.title2) } } - + Menu { Text(L10n.Localizable.ReadingView.ToolbarItem.Title.autoPlay).foregroundColor(.secondary) ForEach(AutoPlayPolicy.allCases) { policy in @@ -217,7 +217,7 @@ private struct UpperPanel: View { .font(.title2) } .menuStyle(BorderlessButtonMenuStyle()) - + ToolbarFeaturesMenu { Button(action: retryAllFailedImagesAction) { Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) @@ -365,7 +365,7 @@ private struct LowerPanel: View { .gesture(dismissGesture) .opacity(showsSliderPreview ? 0 : 1) } - + // Slider in Liquid Glass Bubble if #available(iOS 26.0, *) { VStack(spacing: 0) { @@ -388,7 +388,7 @@ private struct LowerPanel: View { Text(isReversed ? "\(Int(range.lowerBound))" : "\(Int(range.upperBound))") .fontWeight(.medium).font(.caption).padding() } - .padding(.horizontal) //.padding(.bottom) + .padding(.horizontal) .glassEffect() } } diff --git a/EhPanda/View/Reading/Support/GestureCoordinator.swift b/EhPanda/View/Reading/Support/GestureCoordinator.swift index 7680652c..62017f4a 100644 --- a/EhPanda/View/Reading/Support/GestureCoordinator.swift +++ b/EhPanda/View/Reading/Support/GestureCoordinator.swift @@ -13,26 +13,26 @@ final class GestureCoordinator: ObservableObject { @Published var scale: Double = 1.0 @Published var offset: CGSize = .zero @Published var dragStartOffset: CGSize = .zero - + // MARK: - Private Properties private var baseScale: Double = 1.0 private var baseOffset: CGSize = .zero private var currentPanOffset: CGSize = .zero private var setting: Setting = .init() - + // MARK: - Configuration private var gestureConfig: GestureConfiguration = .init() - + // MARK: - Setup func setup(setting: Setting) { self.setting = setting gestureConfig = GestureConfiguration(setting: setting) } - + func cleanup() { resetToDefaults() } - + private func resetToDefaults() { scale = 1.0 offset = .zero @@ -40,9 +40,9 @@ final class GestureCoordinator: ObservableObject { baseScale = 1.0 baseOffset = .zero } - + // MARK: - Gesture Handlers - + /// Handles single tap gestures for page navigation or panel toggling func handleSingleTap( readingDirection: ReadingDirection, @@ -50,7 +50,7 @@ final class GestureCoordinator: ObservableObject { onTogglePanel: @escaping () -> Void ) { Logger.info("Handle single tap", context: ["readingDirection": readingDirection]) - + // For vertical reading, always toggle panel guard readingDirection != .vertical, let touchPoint = TouchHandler.shared.currentPoint @@ -58,24 +58,29 @@ final class GestureCoordinator: ObservableObject { onTogglePanel() return } - + let tapRegion = determineTapRegion(point: touchPoint) - handleTapRegion(tapRegion, readingDirection: readingDirection, onPageNavigation: onPageNavigation, onTogglePanel: onTogglePanel) + handleTapRegion( + tapRegion, + readingDirection: readingDirection, + onPageNavigation: onPageNavigation, + onTogglePanel: onTogglePanel + ) } - + /// Handles double tap gestures for zoom func handleDoubleTap() { Logger.info("Handle double tap", context: [ "currentScale": scale, "doubleTapScale": setting.doubleTapScaleFactor ]) - + let targetScale = scale == 1.0 ? setting.doubleTapScaleFactor : 1.0 - + if let touchPoint = TouchHandler.shared.currentPoint { updateScaleAnchor(for: touchPoint) } - + withAnimation(.easeInOut(duration: 0.25)) { scale = targetScale if targetScale == 1.0 { @@ -83,33 +88,33 @@ final class GestureCoordinator: ObservableObject { scaleAnchor = .center } } - + baseScale = scale baseOffset = offset } - + /// Handles magnification (pinch) gestures func handleMagnificationChanged(value: Double) { Logger.info("Handle magnification changed", context: ["value": value]) - + if value == 1.0 { baseScale = scale } - + if let touchPoint = TouchHandler.shared.currentPoint { updateScaleAnchor(for: touchPoint) } - + let newScale = min(max(value * baseScale, 1.0), setting.maximumScaleFactor) scale = newScale constrainOffset() } - + func handleMagnificationEnded(value: Double) { Logger.info("Handle magnification ended", context: ["value": value]) - + let finalScale = min(max(value * baseScale, 1.0), setting.maximumScaleFactor) - + // Snap to 1.0 if very close if abs(finalScale - 1.0) < 0.05 { withAnimation(.easeOut(duration: 0.2)) { @@ -122,40 +127,40 @@ final class GestureCoordinator: ObservableObject { // Apply constraints after scale change to ensure proper bounds constrainOffset() } - + baseScale = scale baseOffset = offset } - + /// Handles drag gestures for panning when zoomed func handleDragChanged(value: DragGesture.Value) { guard scale > 1.0 else { return } - + Logger.info("Handle drag changed", context: [ "translation": value.translation, "scale": scale, "currentPanOffset": currentPanOffset ]) - + // Add high sensitivity multiplier for more responsive movement let sensitivity: CGFloat = 2.0 let adjustedTranslation = CGSize( width: value.translation.width * sensitivity, height: value.translation.height * sensitivity ) - + // Update current pan offset currentPanOffset = adjustedTranslation - + // Calculate total offset (base + current pan) let totalOffset = CGSize( width: baseOffset.width + currentPanOffset.width, height: baseOffset.height + currentPanOffset.height ) - + // Apply boundary constraints to prevent dragging beyond image edges offset = constrainOffset(totalOffset) - + Logger.info("Offset updated", context: [ "adjustedTranslation": adjustedTranslation, "currentPanOffset": currentPanOffset, @@ -163,42 +168,42 @@ final class GestureCoordinator: ObservableObject { "constrainedOffset": offset ]) } - + func handleDragStarted() { guard scale > 1.0 else { return } Logger.info("Handle drag started") currentPanOffset = .zero } - + func handleDragEnded(value: DragGesture.Value) { guard scale > 1.0 else { return } Logger.info("Handle drag ended") - + // Ensure the final position is properly constrained let finalOffset = constrainOffset(offset) offset = finalOffset - + // Update base offset with final constrained position baseOffset = finalOffset currentPanOffset = .zero } - + /// Handles control panel dismiss gesture func handleControlPanelDismiss(value: DragGesture.Value, dismissAction: @escaping () -> Void) { Logger.info("Handle control panel dismiss", context: ["translation": value.translation]) - + if value.predictedEndTranslation.height > 30 { dismissAction() } } - + // MARK: - Private Helper Methods - + private func determineTapRegion(point: CGPoint) -> TapRegion { let screenWidth = DeviceUtil.absWindowW let leftThreshold = screenWidth * 0.2 let rightThreshold = screenWidth * 0.8 - + if point.x < leftThreshold { return .left } else if point.x > rightThreshold { @@ -207,7 +212,7 @@ final class GestureCoordinator: ObservableObject { return .center } } - + private func handleTapRegion( _ region: TapRegion, readingDirection: ReadingDirection, @@ -215,7 +220,7 @@ final class GestureCoordinator: ObservableObject { onTogglePanel: @escaping () -> Void ) { let isRightToLeft = readingDirection == .rightToLeft - + switch region { case .left: onPageNavigation(isRightToLeft ? 1 : -1) @@ -225,36 +230,36 @@ final class GestureCoordinator: ObservableObject { onTogglePanel() } } - + private func updateScaleAnchor(for point: CGPoint) { let normalizedX = min(1, max(0, point.x / DeviceUtil.absWindowW)) let normalizedY = min(1, max(0, point.y / DeviceUtil.absWindowH)) scaleAnchor = UnitPoint(x: normalizedX, y: normalizedY) } - + @discardableResult private func constrainOffset(_ newOffset: CGSize? = nil) -> CGSize { let targetOffset = newOffset ?? offset - + // Calculate the maximum allowed offset based on scale and screen size let screenWidth = DeviceUtil.absWindowW let screenHeight = DeviceUtil.absWindowH - + // When scaled, the image is larger than the screen, so we need to constrain // the offset to keep the image content visible let maxOffsetX = screenWidth * (scale - 1) / 2 let maxOffsetY = screenHeight * (scale - 1) / 2 - + // Apply constraints to keep the image within bounds let constrainedWidth = min(max(targetOffset.width, -maxOffsetX), maxOffsetX) let constrainedHeight = min(max(targetOffset.height, -maxOffsetY), maxOffsetY) - + let constrained = CGSize(width: constrainedWidth, height: constrainedHeight) - + if newOffset == nil { offset = constrained } - + return constrained } } @@ -269,7 +274,7 @@ private struct GestureConfiguration { let tapRegionThreshold: Double let snapToOneThreshold: Double let panVelocityThreshold: Double - + init(setting: Setting? = nil) { self.tapRegionThreshold = 0.2 self.snapToOneThreshold = 0.05 @@ -294,15 +299,15 @@ extension View { page: page, onTogglePanel: onTogglePanel ) - + let magnificationGesture = createMagnificationGesture( gestureCoordinator: gestureCoordinator ) - + let dragGesture = createDragGesture( gestureCoordinator: gestureCoordinator ) - + return self .gesture(dragGesture, isEnabled: gestureCoordinator.scale > 1) .simultaneousGesture( @@ -312,7 +317,7 @@ extension View { .gesture(tapGesture, isEnabled: gestureCoordinator.scale == 1) .gesture(magnificationGesture) } - + private func createTapGesture( gestureCoordinator: GestureCoordinator, pageCoordinator: PageCoordinator, @@ -332,15 +337,15 @@ extension View { onTogglePanel: onTogglePanel ) } - + let doubleTap = TapGesture(count: 2) .onEnded { gestureCoordinator.handleDoubleTap() } - + return ExclusiveGesture(doubleTap, singleTap) } - + private func createMagnificationGesture( gestureCoordinator: GestureCoordinator ) -> some Gesture { @@ -352,7 +357,7 @@ extension View { gestureCoordinator.handleMagnificationEnded(value: value) } } - + private func createDragGesture( gestureCoordinator: GestureCoordinator ) -> some Gesture { @@ -364,4 +369,4 @@ extension View { gestureCoordinator.handleDragEnded(value: value) } } -} +} diff --git a/EhPanda/View/Reading/Support/ImageStackView.swift b/EhPanda/View/Reading/Support/ImageStackView.swift index e96b7f09..83103406 100644 --- a/EhPanda/View/Reading/Support/ImageStackView.swift +++ b/EhPanda/View/Reading/Support/ImageStackView.swift @@ -15,18 +15,18 @@ struct ImageStackView: View { @Binding private var setting: Setting @ObservedObject private var viewModel: ReadingViewModel @ObservedObject private var gestureCoordinator: GestureCoordinator - + // MARK: - Computed Properties private var isDualPage: Bool { - setting.enablesDualPageMode && - setting.readingDirection != .vertical && + setting.enablesDualPageMode && + setting.readingDirection != .vertical && DeviceUtil.isLandscape } - + private var backgroundColor: Color { Color(.systemGray4) // This should match the main view's background } - + private var imageStackConfig: ImageStackConfig { let dualPageConfig = pageCoordinator.getDualPageConfiguration( for: index, @@ -34,7 +34,7 @@ struct ImageStackView: View { ) return ImageStackConfig(from: dualPageConfig) } - + // MARK: - Dependencies private var pageCoordinator: PageCoordinator { // This would ideally be injected, but for now we create a temporary one @@ -42,7 +42,7 @@ struct ImageStackView: View { coordinator.setup(pageCount: store.gallery.pageCount, setting: setting) return coordinator } - + // MARK: - Initialization init( index: Int, @@ -57,7 +57,7 @@ struct ImageStackView: View { self.viewModel = viewModel self.gestureCoordinator = gestureCoordinator } - + // MARK: - Body var body: some View { HStack(spacing: 0) { @@ -71,7 +71,7 @@ struct ImageStackView: View { backgroundColor: backgroundColor ) } - + if imageStackConfig.isSecondAvailable { ImageContainerView( index: imageStackConfig.secondIndex, @@ -95,30 +95,30 @@ private struct ImageContainerView: View { @ObservedObject private var viewModel: ReadingViewModel private let isDualPage: Bool private let backgroundColor: Color - + // MARK: - Computed Properties private var imageURL: URL? { store.imageURLs[index] } - + private var originalImageURL: URL? { store.originalImageURLs[index] } - + private var loadingState: LoadingState { store.imageURLLoadingStates[index] ?? .idle } - + private var liveTextGroups: [LiveTextGroup] { viewModel.liveTextGroups[index] ?? [] } - + private var containerSize: CGSize { let width = DeviceUtil.windowW / (isDualPage ? 2 : 1) let height = width / Defaults.ImageSize.contentAspect return CGSize(width: width, height: height) } - + // MARK: - Initialization init( index: Int, @@ -135,7 +135,7 @@ private struct ImageContainerView: View { self.isDualPage = isDualPage self.backgroundColor = backgroundColor } - + // MARK: - Body var body: some View { Group { @@ -152,7 +152,7 @@ private struct ImageContainerView: View { contextMenuItems } } - + // MARK: - Success View private var successView: some View { ZStack { @@ -168,7 +168,7 @@ private struct ImageContainerView: View { ) } } - + // MARK: - Image View @ViewBuilder private var imageView: some View { @@ -190,7 +190,7 @@ private struct ImageContainerView: View { placeholderView(Progress()) } } - + // MARK: - Placeholder View private func placeholderView(_ progress: Progress = Progress()) -> some View { Placeholder( @@ -203,17 +203,17 @@ private struct ImageContainerView: View { ) .frame(width: containerSize.width, height: containerSize.height) } - + // MARK: - Loading/Error View private var loadingOrErrorView: some View { ZStack { backgroundColor - + VStack(spacing: 30) { Text("\(index)") .font(.largeTitle.bold()) .foregroundColor(.gray) - + ZStack { if loadingState == .loading { ProgressView() @@ -229,7 +229,7 @@ private struct ImageContainerView: View { } .frame(width: containerSize.width, height: containerSize.height) } - + // MARK: - Context Menu @ViewBuilder private var contextMenuItems: some View { @@ -239,32 +239,40 @@ private struct ImageContainerView: View { systemSymbol: .arrowCounterclockwise ) } - + if let imageURL = imageURL { - Button(action: { handleCopyImage(imageURL) }) { + Button { + handleCopyImage(imageURL) + } label: { Label( L10n.Localizable.ReadingView.ContextMenu.Button.copy, systemSymbol: .plusSquareOnSquare ) } - - Button(action: { handleSaveImage(imageURL) }) { + + Button { + handleSaveImage(imageURL) + } label: { Label( L10n.Localizable.ReadingView.ContextMenu.Button.save, systemSymbol: .squareAndArrowDown ) } - + if let originalImageURL = originalImageURL { - Button(action: { handleSaveImage(originalImageURL) }) { + Button { + handleSaveImage(originalImageURL) + } label: { Label( L10n.Localizable.ReadingView.ContextMenu.Button.saveOriginal, systemSymbol: .squareAndArrowDownOnSquare ) } } - - Button(action: { handleShareImage(imageURL) }) { + + Button { + handleShareImage(imageURL) + } label: { Label( L10n.Localizable.ReadingView.ContextMenu.Button.share, systemSymbol: .squareAndArrowUp @@ -272,11 +280,11 @@ private struct ImageContainerView: View { } } } - + // MARK: - Event Handlers private func handleAppear() { let isDatabaseLoading = store.databaseLoadingState != .idle - + if !isDatabaseLoading { if imageURL == nil { store.send(.fetchImageURLs(index)) @@ -284,10 +292,10 @@ private struct ImageContainerView: View { store.send(.prefetchImages(index, setting.prefetchLimit)) } } - + private func handleImageSuccess() { store.send(.onWebImageSucceeded(index)) - + if viewModel.enablesLiveText { viewModel.analyzeImageForLiveText( index: index, @@ -296,11 +304,11 @@ private struct ImageContainerView: View { ) } } - + private func handleImageFailure() { store.send(.onWebImageFailed(index)) } - + private func handleReloadTap() { if case .failed(let error) = loadingState { if case .webImageFailed = error { @@ -310,19 +318,19 @@ private struct ImageContainerView: View { } } } - + private func handleRefetch() { store.send(.refetchImageURLs(index)) } - + private func handleCopyImage(_ url: URL) { store.send(.copyImage(url)) } - + private func handleSaveImage(_ url: URL) { store.send(.saveImage(url)) } - + private func handleShareImage(_ url: URL) { store.send(.shareImage(url)) } @@ -344,4 +352,4 @@ struct ImageStackView_Previews: PreviewProvider { .previewLayout(.sizeThatFits) .padding() } -} +} diff --git a/EhPanda/View/Reading/Support/PageCoordinator.swift b/EhPanda/View/Reading/Support/PageCoordinator.swift index 9181dc19..fa2d98f1 100644 --- a/EhPanda/View/Reading/Support/PageCoordinator.swift +++ b/EhPanda/View/Reading/Support/PageCoordinator.swift @@ -14,49 +14,49 @@ final class PageCoordinator: ObservableObject { Logger.info("Slider value changed", context: ["sliderValue": sliderValue]) } } - + // MARK: - Private Properties private var pageCount: Int = 1 private var setting: Setting = .init() private var cancellables = Set() - + // MARK: - Configuration private var pageConfig: PageConfiguration = .init() - + // MARK: - Initialization init() { setupObservers() } - + deinit { cleanup() } - + // MARK: - Setup Methods func setup(pageCount: Int, setting: Setting) { self.pageCount = pageCount self.setting = setting self.pageConfig = PageConfiguration(setting: setting) - + Logger.info("Page coordinator setup", context: [ "pageCount": pageCount, "readingDirection": setting.readingDirection.rawValue ]) } - + func setup(pageCount: Int, setting: Setting, initialPage: Int) { setup(pageCount: pageCount, setting: setting) - + // Initialize slider value with reading progress let validProgress = max(1, min(initialPage, pageCount)) sliderValue = Float(validProgress) - + Logger.info("Page coordinator setup with initial page", context: [ "initialPage": initialPage, "validProgress": validProgress ]) } - + private func setupObservers() { // Observe slider value changes for page navigation $sliderValue @@ -66,13 +66,13 @@ final class PageCoordinator: ObservableObject { } .store(in: &cancellables) } - + func cleanup() { cancellables.removeAll() } - + // MARK: - Page Mapping Methods - + /// Maps from pager index to page number func mapFromPager( index: Int, @@ -85,15 +85,15 @@ final class PageCoordinator: ObservableObject { "pageCount": pageCount, "isDualPage": isDualPageMode(setting: setting, isLandscape: isLandscape) ]) - + guard isDualPageMode(setting: setting, isLandscape: isLandscape) else { return index + 1 } - + guard index > 0 else { return 1 } - + let result = setting.exceptCover ? index * 2 : index * 2 + 1 - + // Handle edge case for last page in dual mode if result + 1 == pageCount { return pageCount @@ -101,7 +101,7 @@ final class PageCoordinator: ObservableObject { return result } } - + /// Maps from page number to pager index func mapToPager( index: Int, @@ -112,63 +112,63 @@ final class PageCoordinator: ObservableObject { "index": index, "isDualPage": isDualPageMode(setting: setting, isLandscape: isLandscape) ]) - + guard isDualPageMode(setting: setting, isLandscape: isLandscape) else { return index - 1 } - + guard index > 1 else { return 0 } - + return setting.exceptCover ? index / 2 : (index - 1) / 2 } - + // MARK: - Page Navigation - + /// Updates the current page and synchronizes slider func updateCurrentPage(_ pageIndex: Int) { let clampedIndex = max(1, min(pageIndex, pageCount)) sliderValue = Float(clampedIndex) - + Logger.info("Updated current page", context: [ "pageIndex": pageIndex, "clampedIndex": clampedIndex ]) } - + /// Handles page navigation with bounds checking func navigatePage(offset: Int, currentIndex: Int) -> Int { let newIndex = currentIndex + offset let clampedIndex = max(0, min(newIndex, pageCount - 1)) - + Logger.info("Navigate page", context: [ "offset": offset, "currentIndex": currentIndex, "newIndex": newIndex, "clampedIndex": clampedIndex ]) - + return clampedIndex } - + /// Gets valid page range for the current configuration func getValidPageRange() -> ClosedRange { return 1...pageCount } - + /// Checks if a page index is valid func isValidPageIndex(_ index: Int) -> Bool { return index >= 1 && index <= pageCount } - + // MARK: - Dual Page Support - + /// Determines if dual page mode should be active func isDualPageMode(setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> Bool { - return isLandscape && - setting.enablesDualPageMode && + return isLandscape && + setting.enablesDualPageMode && setting.readingDirection != .vertical } - + /// Gets the page configuration for dual page mode func getDualPageConfiguration( for index: Int, @@ -179,15 +179,15 @@ final class PageCoordinator: ObservableObject { let isReversed = setting.readingDirection == .rightToLeft let isFirstSingle = setting.exceptCover let isFirstPageAndSingle = index == 1 && isFirstSingle - + let firstIndex = isDualPage && isReversed && !isFirstPageAndSingle ? index + 1 : index let secondIndex = firstIndex + (isReversed ? -1 : 1) - + let isValidFirstRange = firstIndex >= 1 && firstIndex <= pageCount - let isValidSecondRange = isFirstSingle - ? secondIndex >= 2 && secondIndex <= pageCount + let isValidSecondRange = isFirstSingle + ? secondIndex >= 2 && secondIndex <= pageCount : secondIndex >= 1 && secondIndex <= pageCount - + return DualPageConfiguration( firstIndex: firstIndex, secondIndex: secondIndex, @@ -196,24 +196,24 @@ final class PageCoordinator: ObservableObject { isDualPage: isDualPage ) } - + // MARK: - Auto Play Support - + /// Gets the next page index for auto play func getNextAutoPlayIndex(currentIndex: Int) -> Int? { let nextIndex = currentIndex + 1 guard nextIndex < pageCount else { return nil } return nextIndex } - + // MARK: - Private Methods - + private func handleSliderValueChange(_ newValue: Float) { Logger.info("Handle slider value change", context: [ "newValue": newValue, "pageCount": pageCount ]) - + // Validate slider value let clampedValue = max(1, min(newValue, Float(pageCount))) if clampedValue != newValue { @@ -240,7 +240,7 @@ private struct PageConfiguration { let enablesDualPage: Bool let exceptCover: Bool let readingDirection: ReadingDirection - + init(setting: Setting? = nil) { self.enablesDualPage = setting?.enablesDualPageMode ?? false self.exceptCover = setting?.exceptCover ?? false @@ -258,21 +258,21 @@ extension PageCoordinator { isLandscape: Bool = DeviceUtil.isLandscape ) -> [Int] { let defaultData = Array(1...pageCount) - + guard isDualPageMode(setting: setting, isLandscape: isLandscape) else { return defaultData } - + let data = setting.exceptCover ? [1] + Array(stride(from: 2, through: pageCount, by: 2)) : Array(stride(from: 1, through: pageCount, by: 2)) - + Logger.info("Generated container data source", context: [ "defaultCount": defaultData.count, "dualPageCount": data.count, "exceptCover": setting.exceptCover ]) - + return data } } @@ -285,11 +285,11 @@ struct ImageStackConfig { let secondIndex: Int let isFirstAvailable: Bool let isSecondAvailable: Bool - + init(from dualPageConfig: DualPageConfiguration) { self.firstIndex = dualPageConfig.firstIndex self.secondIndex = dualPageConfig.secondIndex self.isFirstAvailable = dualPageConfig.isFirstAvailable self.isSecondAvailable = dualPageConfig.isSecondAvailable } -} +} diff --git a/EhPanda/View/Reading/Support/ReadingViewExtensions.swift b/EhPanda/View/Reading/Support/ReadingViewExtensions.swift index 08e8ab6b..4af25e53 100644 --- a/EhPanda/View/Reading/Support/ReadingViewExtensions.swift +++ b/EhPanda/View/Reading/Support/ReadingViewExtensions.swift @@ -10,7 +10,7 @@ import ComposableArchitecture // MARK: - Auto Play Policy enum AutoPlayPolicy: Int, CaseIterable, Identifiable { var id: Int { rawValue } - + case off = -1 case sec1 = 1 case sec2 = 2 @@ -29,12 +29,12 @@ extension AutoPlayPolicy { return L10n.Localizable.Common.Value.seconds("\(rawValue)") } } - + /// Time interval for the timer (0 means disabled) var timeInterval: TimeInterval { return rawValue > 0 ? TimeInterval(rawValue) : 0 } - + /// Whether auto play is enabled var isEnabled: Bool { return self != .off @@ -56,7 +56,7 @@ extension View { .readingAnimations() .readingStatusBar(store: store) } - + /// Applies reading-specific sheet presentations private func readingSheets( store: StoreOf, @@ -95,7 +95,7 @@ extension View { .autoBlur(radius: blurRadius) } } - + /// Applies progress HUD for reading operations private func readingProgressHUD(store: StoreOf) -> some View { self.progressHUD( @@ -107,14 +107,14 @@ extension View { case: \.hud ) } - + /// Applies reading-specific animations private func readingAnimations() -> some View { self .animation(.linear(duration: 0.1), value: UUID()) // Placeholder for gesture animations .animation(.default, value: UUID()) // Placeholder for other animations } - + /// Configures status bar visibility private func readingStatusBar(store: StoreOf) -> some View { self.statusBar(hidden: !store.showsPanel) @@ -205,7 +205,7 @@ extension View { handleLandscapeSettingChange(newValue: newValue, store: store) } } - + private func handlePageIndexChange( newValue: Int, store: StoreOf, @@ -213,20 +213,21 @@ extension View { pageCoordinator: PageCoordinator ) { Logger.info("Page index changed", context: ["pageIndex": newValue]) - + let mappedValue = pageCoordinator.mapFromPager( index: newValue, pageCount: store.gallery.pageCount, setting: setting ) - + pageCoordinator.sliderValue = Float(mappedValue) - + if store.databaseLoadingState == .idle { store.send(.syncReadingProgress(mappedValue)) } } - + + // swiftlint:disable:next function_parameter_count private func handleSliderValueChange( newValue: Float, store: StoreOf, @@ -236,7 +237,7 @@ extension View { setting: Setting ) { Logger.info("Slider value changed", context: ["sliderValue": newValue]) - + if !showsSliderPreview { let pagerIndex = pageCoordinator.mapToPager(index: Int(newValue), setting: setting) if page.index != pagerIndex { @@ -245,7 +246,7 @@ extension View { } } } - + private func handleSliderPreviewChange( newValue: Bool, pageCoordinator: PageCoordinator, @@ -254,7 +255,7 @@ extension View { setting: Setting ) { Logger.info("Slider preview changed", context: ["isShown": newValue]) - + if !newValue { let pagerIndex = pageCoordinator.mapToPager( index: Int(pageCoordinator.sliderValue), @@ -264,10 +265,10 @@ extension View { page.update(.new(index: pagerIndex)) } } - + viewModel.stopAutoPlay() } - + private func handleReadingProgressChange( newValue: Int, pageCoordinator: PageCoordinator, @@ -275,13 +276,13 @@ extension View { setting: Setting ) { Logger.info("Reading progress changed", context: ["readingProgress": newValue]) - + // Ensure valid reading progress (at least page 1) let validProgress = max(1, newValue) - + // Update slider value pageCoordinator.sliderValue = Float(validProgress) - + // Update pager position to match the reading progress let pagerIndex = pageCoordinator.mapToPager(index: validProgress, setting: setting) if page.index != pagerIndex { @@ -292,22 +293,22 @@ extension View { ]) } } - + private func handleRouteChange(newValue: ReadingReducer.Route?, viewModel: ReadingViewModel) { Logger.info("Route changed", context: ["route": newValue as Any]) - + if let route = newValue, ![ReadingReducer.Route.hud, nil].contains(where: { $0 == route }) { viewModel.stopAutoPlay() } } - + private func handleLiveTextToggle( newValue: Bool, store: StoreOf, viewModel: ReadingViewModel ) { Logger.info("Live text toggled", context: ["isEnabled": newValue]) - + if newValue { store.webImageLoadSuccessIndices.forEach { index in viewModel.analyzeImageForLiveText( @@ -318,7 +319,7 @@ extension View { } } } - + private func handleImageLoadSuccess( newValue: Set, viewModel: ReadingViewModel, @@ -327,7 +328,7 @@ extension View { Logger.info("Image load success indices changed", context: [ "count": newValue.count ]) - + if viewModel.enablesLiveText { newValue.forEach { index in viewModel.analyzeImageForLiveText( @@ -338,7 +339,7 @@ extension View { } } } - + private func handleLandscapeSettingChange(newValue: Bool, store: StoreOf) { Logger.info("Landscape setting changed", context: ["newValue": newValue]) store.send(.setOrientationPortrait(!newValue)) @@ -355,7 +356,7 @@ struct ReadingControlPanel: View { @Binding private var setting: Setting @Binding private var enablesLiveText: Bool @Binding private var autoPlayPolicy: AutoPlayPolicy - + private let range: ClosedRange private let previewURLs: [Int: URL] private let dismissGesture: G @@ -364,7 +365,7 @@ struct ReadingControlPanel: View { private let reloadAllImagesAction: () -> Void private let retryAllFailedImagesAction: () -> Void private let fetchPreviewURLsAction: (Int) -> Void - + init( showsPanel: Binding, showsSliderPreview: Binding, @@ -396,7 +397,7 @@ struct ReadingControlPanel: View { self.retryAllFailedImagesAction = retryAllFailedImagesAction self.fetchPreviewURLsAction = fetchPreviewURLsAction } - + var body: some View { ControlPanel( showsPanel: $showsPanel, @@ -426,18 +427,18 @@ extension ReadingReducer.Route { } return nil } - + var share: IdentifiableBox? { if case .share(let shareItem) = self { return shareItem } return nil } - + var hud: Void? { if case .hud = self { return () } return nil } -} +} diff --git a/EhPanda/View/Reading/Support/ReadingViewModel.swift b/EhPanda/View/Reading/Support/ReadingViewModel.swift index 4e3e51be..bc5c3e8b 100644 --- a/EhPanda/View/Reading/Support/ReadingViewModel.swift +++ b/EhPanda/View/Reading/Support/ReadingViewModel.swift @@ -15,32 +15,32 @@ final class ReadingViewModel: ObservableObject { @Published var focusedLiveTextGroup: LiveTextGroup? @Published var autoPlayPolicy: AutoPlayPolicy = .off @Published var webImageLoadSuccessIndices = Set() - + // MARK: - Private Properties private var autoPlayTimer: Timer? private var liveTextRequests = [VNRequest]() private var cancellables = Set() - + // MARK: - Initialization init() { setupObservers() } - + deinit { cleanup() } - + // MARK: - Setup Methods func setup(with state: ReadingReducer.State, setting: Setting) { // Initialize with current state webImageLoadSuccessIndices = state.webImageLoadSuccessIndices - + // Setup live text if needed if enablesLiveText { analyzeExistingImages(indices: Array(webImageLoadSuccessIndices)) } } - + private func setupObservers() { // Observe live text state changes $enablesLiveText @@ -53,39 +53,39 @@ final class ReadingViewModel: ObservableObject { } .store(in: &cancellables) } - + // MARK: - Auto Play Management func setAutoPlayPolicy(_ policy: AutoPlayPolicy, pageUpdater: @escaping () -> Void) { Logger.info("Setting auto play policy", context: ["policy": policy]) - + autoPlayPolicy = policy autoPlayTimer?.invalidate() - + if policy.isEnabled { autoPlayTimer = Timer.scheduledTimer(withTimeInterval: policy.timeInterval, repeats: true) { _ in pageUpdater() } } } - + func stopAutoPlay() { autoPlayTimer?.invalidate() autoPlayPolicy = .off } - + // MARK: - Live Text Management func setFocusedLiveTextGroup(_ group: LiveTextGroup) { Logger.info("Setting focused live text group", context: ["group": group]) focusedLiveTextGroup = group } - + func analyzeImageForLiveText( index: Int, imageURL: URL?, recognitionLanguages: [String]? ) { Logger.info("Analyzing image for live text", context: ["index": index]) - + guard enablesLiveText, liveTextGroups[index] == nil, let imageURL = imageURL, @@ -98,7 +98,7 @@ final class ReadingViewModel: ObservableObject { ]) return } - + KingfisherManager.shared.cache.retrieveImage(forKey: key) { [weak self] result in switch result { case .success(let result): @@ -120,14 +120,14 @@ final class ReadingViewModel: ObservableObject { } } } - + private func analyzeExistingImages(indices: [Int]) { - indices.forEach { index in + indices.forEach { _ in // This would be called with proper parameters from the main view // analyzeImageForLiveText(index: index, imageURL: nil, recognitionLanguages: nil) } } - + private func performLiveTextAnalysis( cgImage: CGImage, size: CGSize, @@ -143,16 +143,16 @@ final class ReadingViewModel: ObservableObject { index: index ) } - + textRecognitionRequest.usesLanguageCorrection = true textRecognitionRequest.preferBackgroundProcessing = true - + if let languages = recognitionLanguages { textRecognitionRequest.recognitionLanguages = languages } - + liveTextRequests.append(textRecognitionRequest) - + DispatchQueue.global(qos: .utility).async { [weak self] in do { try requestHandler.perform([textRecognitionRequest]) @@ -162,7 +162,7 @@ final class ReadingViewModel: ObservableObject { } } } - + private func handleLiveTextRecognition( request: VNRequest, error: Error?, @@ -170,27 +170,27 @@ final class ReadingViewModel: ObservableObject { index: Int ) { removeLiveTextRequest(request) - + guard let observations = request.results as? [VNRecognizedTextObservation] else { return } - + DispatchQueue.global(qos: .userInteractive).async { [weak self] in let blocks = self?.processLiveTextObservations(observations) ?? [] let groups = self?.groupLiveTextBlocks(blocks, size: size) ?? [] - + DispatchQueue.main.async { self?.liveTextGroups[index] = groups } } } - + private func processLiveTextObservations(_ observations: [VNRecognizedTextObservation]) -> [LiveTextBlock] { return observations.compactMap { observation in guard let recognizedText = observation.topCandidates(1).first?.string else { return nil } - + return LiveTextBlock( text: recognizedText, bounds: LiveTextBounds( @@ -202,10 +202,10 @@ final class ReadingViewModel: ObservableObject { ) } } - + private func groupLiveTextBlocks(_ blocks: [LiveTextBlock], size: CGSize) -> [LiveTextGroup] { var groupData = [[LiveTextBlock]]() - + blocks.forEach { newBlock in if let groupIndex = findMatchingGroup(for: newBlock, in: groupData, size: size) { groupData[groupIndex].append(newBlock) @@ -213,10 +213,10 @@ final class ReadingViewModel: ObservableObject { groupData.append([newBlock]) } } - + return groupData.compactMap(LiveTextGroup.init) } - + private func findMatchingGroup( for newBlock: LiveTextBlock, in groupData: [[LiveTextBlock]], @@ -228,7 +228,7 @@ final class ReadingViewModel: ObservableObject { } } } - + private func areLiveTextBlocksCompatible( _ block1: LiveTextBlock, _ block2: LiveTextBlock, @@ -238,33 +238,33 @@ final class ReadingViewModel: ObservableObject { let angle2 = block2.bounds.getAngle(size) let angleDiff = abs(angle1 - angle2).truncatingRemainder(dividingBy: 360.0) let isAngleValid = angleDiff < 5 || angleDiff > (360 - 5) - + let height1 = block1.bounds.getHeight(size) let height2 = block2.bounds.getHeight(size) let isHeightValid = abs(height1 - height2) < (min(height1, height2) / 2) - + guard isAngleValid && isHeightValid else { return false } - + return arePolygonsIntersecting( lhs: block1.bounds.expandingHalfHeight(size).edges, rhs: block2.bounds.expandingHalfHeight(size).edges ) } - + private func arePolygonsIntersecting(lhs: [CGPoint], rhs: [CGPoint]) -> Bool { guard !lhs.isEmpty, !rhs.isEmpty, lhs.count == rhs.count else { return false } - + for points in [lhs, rhs] { for index1 in 0.. (min: Double, max: Double) { let projections = points.map { point in basis.x * point.x + basis.y * point.y } return (projections.min() ?? 0, projections.max() ?? 0) } - + private func clearLiveText() { liveTextGroups.removeAll() focusedLiveTextGroup = nil cancelLiveTextRequests() } - + private func removeLiveTextRequest(_ request: VNRequest) { if let index = liveTextRequests.firstIndex(of: request) { liveTextRequests.remove(at: index) } } - + private func cancelLiveTextRequests() { Logger.info("Canceling live text requests", context: [ "count": liveTextRequests.count @@ -299,7 +299,7 @@ final class ReadingViewModel: ObservableObject { liveTextRequests.forEach { $0.cancel() } liveTextRequests.removeAll() } - + // MARK: - Cleanup func cleanup() { autoPlayTimer?.invalidate() @@ -316,4 +316,4 @@ private extension CGPoint { } // MARK: - Import Vision Framework -import Vision +import Vision From 370dc2a103c0c7daae79f71dab49ba3f5844d93d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 13 Oct 2025 11:39:30 +0800 Subject: [PATCH 20/40] Update target system to 26.0 --- .github/workflows/dependencies.yml | 4 ++-- .github/workflows/deploy-pre-release.yml | 6 +++--- .github/workflows/deploy.yml | 6 +++--- .github/workflows/test.yml | 6 +++--- EhPanda.xcodeproj/project.pbxproj | 12 ++++++------ EhPanda/App/Tools/Clients/FileClient.swift | 2 +- EhPanda/App/Tools/Utilities/DeviceUtil.swift | 4 ++-- README.md | 2 +- READMEs/README.chs.md | 2 +- READMEs/README.cht.md | 2 +- READMEs/README.de.md | 2 +- READMEs/README.jpn.md | 2 +- READMEs/README.ko.md | 2 +- 13 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index 6e3fe890..ead627b2 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -8,7 +8,7 @@ permissions: pull-requests: write jobs: dependencies: - runs-on: macos-15 + runs-on: macos-26 steps: - uses: actions/checkout@v4 with: @@ -19,7 +19,7 @@ jobs: with: forceResolution: true failWhenOutdated: false - xcodePath: '/Applications/Xcode_16.4.app' + xcodePath: '/Applications/Xcode_26.0.1.app' - name: Create Pull Request if: steps.resolution.outputs.dependenciesChanged == 'true' uses: peter-evans/create-pull-request@v7 diff --git a/.github/workflows/deploy-pre-release.yml b/.github/workflows/deploy-pre-release.yml index 36a5fb52..a0420bbf 100644 --- a/.github/workflows/deploy-pre-release.yml +++ b/.github/workflows/deploy-pre-release.yml @@ -15,7 +15,7 @@ on: required: true type: string env: - DEVELOPER_DIR: /Applications/Xcode_26_beta.app + DEVELOPER_DIR: /Applications/Xcode_26.0.1.app SCHEME_NAME: 'EhPanda' ALTSTORE_JSON_PATH: './AltStore.json' BUILDS_PATH: '/tmp/action-builds' @@ -26,7 +26,7 @@ env: jobs: Deploy: - runs-on: macos-15 + runs-on: macos-26 if: github.event_name == 'workflow_dispatch' steps: - name: Checkout @@ -43,7 +43,7 @@ jobs: run: xcodebuild clean test -skipMacroValidation -scheme ${{ env.SCHEME_NAME }} - -destination 'platform=iOS Simulator,name=iPhone 16 Pro' + -destination 'platform=iOS Simulator,name=iPhone Air' - name: Bump version id: bump-version uses: yanamura/ios-bump-version@v1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ec7e6ae6..84e90f99 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,7 +5,7 @@ on: - main types: [closed] env: - DEVELOPER_DIR: /Applications/Xcode_26_beta.app + DEVELOPER_DIR: /Applications/Xcode_26.0.1.app APP_VERSION: '2.8.0' SCHEME_NAME: 'EhPanda' ALTSTORE_JSON_PATH: './AltStore.json' @@ -19,7 +19,7 @@ env: jobs: Deploy: - runs-on: macos-15 + runs-on: macos-26 if: | github.event.pull_request.merged == true && ( github.event.pull_request.user.login == 'aalberrty' || @@ -39,7 +39,7 @@ jobs: run: xcodebuild clean test -skipMacroValidation -scheme ${{ env.SCHEME_NAME }} - -destination 'platform=iOS Simulator,name=iPhone 16 Pro' + -destination 'platform=iOS Simulator,name=iPhone Air' - name: Bump version id: bump-version uses: yanamura/ios-bump-version@v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 83d5a138..532b4d60 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,10 +2,10 @@ name: Test on: [push, workflow_dispatch] env: SCHEME_NAME: 'EhPanda' - DEVELOPER_DIR: /Applications/Xcode_26_beta.app + DEVELOPER_DIR: /Applications/Xcode_26.0.1.app jobs: Test: - runs-on: macos-15 + runs-on: macos-26 if: ${{ !contains(github.event.head_commit.message, '[skip test]') }} steps: - name: Checkout @@ -18,4 +18,4 @@ jobs: run: xcodebuild clean test -skipMacroValidation -scheme ${{ env.SCHEME_NAME }} - -destination 'platform=iOS Simulator,name=iPhone 16 Pro' + -destination 'platform=iOS Simulator,name=iPhone Air' diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index f7212022..926fd24d 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -2100,7 +2100,7 @@ INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2128,7 +2128,7 @@ INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2278,7 +2278,7 @@ DEVELOPMENT_TEAM = 9SKQ7QTZ74; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2307,7 +2307,7 @@ DEVELOPMENT_TEAM = 9SKQ7QTZ74; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = EhPanda/App/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2331,7 +2331,7 @@ CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_TEAM = 9SKQ7QTZ74; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2358,7 +2358,7 @@ CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_TEAM = 9SKQ7QTZ74; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/EhPanda/App/Tools/Clients/FileClient.swift b/EhPanda/App/Tools/Clients/FileClient.swift index 7ee659b6..f82d9b31 100644 --- a/EhPanda/App/Tools/Clients/FileClient.swift +++ b/EhPanda/App/Tools/Clients/FileClient.swift @@ -32,7 +32,7 @@ extension FileClient { let logs: [Log] = fileNames.compactMap { name in guard let fileURL = FileUtil.logsDirectoryURL?.appendingPathComponent(name), - let content = try? String(contentsOf: fileURL) + let content = try? String(contentsOf: fileURL, encoding: .utf8) else { return nil } return Log( diff --git a/EhPanda/App/Tools/Utilities/DeviceUtil.swift b/EhPanda/App/Tools/Utilities/DeviceUtil.swift index 531f900a..3a64e266 100644 --- a/EhPanda/App/Tools/Utilities/DeviceUtil.swift +++ b/EhPanda/App/Tools/Utilities/DeviceUtil.swift @@ -36,12 +36,12 @@ struct DeviceUtil { static var isLandscape: Bool { [.landscapeLeft, .landscapeRight] - .contains(keyWindow?.windowScene?.interfaceOrientation) + .contains(keyWindow?.windowScene?.effectiveGeometry.interfaceOrientation) } static var isPortrait: Bool { [.portrait, .portraitUpsideDown] - .contains(keyWindow?.windowScene?.interfaceOrientation) + .contains(keyWindow?.windowScene?.effectiveGeometry.interfaceOrientation) } static var windowW: CGFloat { diff --git a/README.md b/README.md index 5fb894eb..ca0249a8 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/b 2. Use some software like [AltStore](https://altstore.io) to install the ipa file on your device. ## System Requirements -This app requires iOS / iPadOS 17.0 or later. +This app requires iOS / iPadOS 26.0 or later. ## Content & Copyright The content in this application is derived from E-Hentai, which is user-generated content. diff --git a/READMEs/README.chs.md b/READMEs/README.chs.md index 468d79cb..298fee04 100644 --- a/READMEs/README.chs.md +++ b/READMEs/README.chs.md @@ -29,7 +29,7 @@ https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/b 2. 使用 [AltStore](https://altstore.io) 这类软件将 ipa 文件安装到你的设备。 ## 系统要求 -请确保你的设备系统为 iOS / iPadOS 17.0 以上。 +请确保你的设备系统为 iOS / iPadOS 26.0 以上。 ## 内容及其著作权 本应用程序中的内容均来自 E-Hentai,而 E-Hentai 的内容均为用户生成内容。 diff --git a/READMEs/README.cht.md b/READMEs/README.cht.md index 53b3bb86..d9101b2d 100644 --- a/READMEs/README.cht.md +++ b/READMEs/README.cht.md @@ -29,7 +29,7 @@ https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/b 2. 使用 [AltStore](https://altstore.io) 這類軟件將 ipa 文件安裝到你的裝置。 ## 系統需求 -須使用 iOS / iPadOS 17.0 或以上版本。 +須使用 iOS / iPadOS 26.0 或以上版本。 ## 內容及其著作權 本應用程式內的內容均來自 E-Hentai,而 E-Hentai 的內容均為用戶生成內容。 diff --git a/READMEs/README.de.md b/READMEs/README.de.md index 8c966c03..e337f0e9 100644 --- a/READMEs/README.de.md +++ b/READMEs/README.de.md @@ -29,7 +29,7 @@ https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/b 2. Nutze eine Programm zur Installation von nicht im Appstore gelisteten Dateien wie z.B. [AltStore](https://altstore.io) um die IPA-Datei zu installieren. ## Systemanforderungen -Diese App erfordert iOS / iPadOS 17.0 oder neuer. +Diese App erfordert iOS / iPadOS 26.0 oder neuer. ## Inhalte & Copyright Der Inhalt der von dieser App verwaltet wird, wird von E-Hentai geladen. Hierbei handelt es sich um von anderen Nutzern generierten Inhalt. diff --git a/READMEs/README.jpn.md b/READMEs/README.jpn.md index 32da5083..64d6a06a 100644 --- a/READMEs/README.jpn.md +++ b/READMEs/README.jpn.md @@ -29,7 +29,7 @@ https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/b 2. [AltStore](https://altstore.io) とかで ipa ファイルをデバイスにインストール。 ## 必要システム構成 -iOS・iPadOS 17.0 以上が必要です。 +iOS・iPadOS 26.0 以上が必要です。 ## コンテンツとその著作権 本アプリの内容はすべて E-Hentai 由来のもので、E-Hentai の内容もまたすべてユーザー生成コンテンツです。 diff --git a/READMEs/README.ko.md b/READMEs/README.ko.md index b9b593fe..540177a5 100644 --- a/READMEs/README.ko.md +++ b/READMEs/README.ko.md @@ -29,7 +29,7 @@ https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/b 2. [AltStore](https://altstore.io)를 사용해서 ipa 파일을 설치할 수 있습니다. ## 시스템 요구 사항 -iOS / iPadOS 버전이 17.0 이상인지 확인해주세요. +iOS / iPadOS 버전이 26.0 이상인지 확인해주세요. ## 컨텐츠와 저작권 이 앱의 내용은 E-Hentai을 통해 제공되고, E-Hentai의 모든 내용은 앱 이용자가 만듭니다. From 4d2ee6eabf85e6cc14ca519fc99e4a70cbd8eb7b Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 13 Oct 2025 11:39:45 +0800 Subject: [PATCH 21/40] Update dependencies --- .../xcshareddata/swiftpm/Package.resolved | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 65143cfc..8dfd001f 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13", - "version" : "1.0.2" + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "bc92c4b27f9a84bfb498cdbfdf35d5a357e9161f", - "version" : "1.5.6" + "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", + "version" : "1.7.2" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53", - "version" : "1.0.5" + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", - "version" : "1.1.4" + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "d602618c628e5123f66643437151079d3664970d", - "version" : "1.17.0" + "revision" : "2d60d4082dfb4978974307acf0f00dfa20e5f621", + "version" : "1.22.3" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "163409ef7dae9d960b87f34b51587b6609a76c1f", - "version" : "1.3.0" + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "5526c8a27675dc7b18d6fa643abfb64bcb200b77", - "version" : "1.6.2" + "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", + "version" : "1.10.0" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", - "version" : "1.1.0" + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" } }, { @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "ae208d1a5cf33aee1d43734ea780a09ada6e2a21", - "version" : "2.3.2" + "revision" : "91415670c91d41e8e1872ef6fe1bf118e20dee37", + "version" : "2.5.1" } }, { @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "8d52279b9809ef27eabe7d5420f03734528f19da", - "version" : "1.4.1" + "revision" : "30721accd0370d7c9cb5bd0f7cdf5a1a767b383d", + "version" : "2.0.8" } }, { @@ -168,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "b68bf99b05cb974392f6ffa380351e9b7391e233", - "version" : "1.1.0" + "revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818", + "version" : "2.7.4" } }, { @@ -177,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "0687f71944021d616d34d922343dcef086855920", - "version" : "600.0.1" + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" } }, { @@ -249,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "a3f634d1a409c7979cabc0a71b3f26ffa9fc8af1", - "version" : "1.4.3" + "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618", + "version" : "1.7.0" } } ], From f612088666fabd639fbfacd3d8b73eaf87d2b13e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 14 Oct 2025 21:45:44 +0800 Subject: [PATCH 22/40] Resolve AboutView subtitle layout issue --- .../View/Setting/Components/AboutView.swift | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/EhPanda/View/Setting/Components/AboutView.swift b/EhPanda/View/Setting/Components/AboutView.swift index 8f5afcdf..7d3d0feb 100644 --- a/EhPanda/View/Setting/Components/AboutView.swift +++ b/EhPanda/View/Setting/Components/AboutView.swift @@ -15,15 +15,6 @@ struct AboutView: View { } var body: some View { - HStack { - VStack(alignment: .leading) { - Text(L10n.Constant.App.copyright) - Text(version) - } - .foregroundStyle(.gray).font(.caption2.bold()) - Spacer() - } - .padding(.horizontal) Form { Section { ForEach(contacts) { contact in @@ -52,6 +43,18 @@ struct AboutView: View { } } .navigationTitle(L10n.Localizable.AboutView.Title.ehPanda) + .toolbar { + ToolbarItem(placement: .largeSubtitle) { + VStack(alignment: .leading) { + Text(L10n.Constant.App.copyright) + Text(version) + } + .foregroundStyle(.gray) + .font(.caption2.bold()) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } + } } // MARK: Contacts From 670b2ca3e9d17e0674011109af3196c6a2e0bfef Mon Sep 17 00:00:00 2001 From: Chihchy Date: Tue, 14 Oct 2025 22:41:40 +0800 Subject: [PATCH 23/40] Resolve tags extension setting layout issue --- .../GeneralSetting/GeneralSettingView.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift index aeec5944..017f2520 100644 --- a/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift +++ b/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift @@ -68,16 +68,23 @@ struct GeneralSettingView: View { Section(L10n.Localizable.GeneralSettingView.Section.Title.tags) { HStack { Text(L10n.Localizable.GeneralSettingView.Title.enablesTagsExtension) - Spacer() + .frame(maxWidth: .infinity, alignment: .leading) + ZStack { - Image(systemSymbol: .exclamationmarkTriangleFill).foregroundStyle(.yellow) + Image(systemSymbol: .exclamationmarkTriangleFill) + .foregroundStyle(.yellow) .opacity( translatesTags && tagTranslatorEmpty && tagTranslatorLoadingState != .loading ? 1 : 0 ) - ProgressView().tint(nil).opacity(tagTranslatorLoadingState == .loading ? 1 : 0) + ProgressView() + .tint(nil) + .opacity(tagTranslatorLoadingState == .loading ? 1 : 0) } - Toggle("", isOn: $enablesTagsExtension).frame(width: 50) + + Toggle("", isOn: $enablesTagsExtension) + .frame(width: 50) + .padding(.leading, 20) } if enablesTagsExtension && !tagTranslatorEmpty { Toggle(L10n.Localizable.GeneralSettingView.Title.translatesTags, isOn: $translatesTags) From 348bc073f0486f3f482504ac6343ebf4036fea8e Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 19 Oct 2025 15:37:17 +0800 Subject: [PATCH 24/40] Resolve quick search empty cell issue --- EhPanda/View/Search/SearchRootView.swift | 31 ++++++++++++++++++------ 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/EhPanda/View/Search/SearchRootView.swift b/EhPanda/View/Search/SearchRootView.swift index 033346cd..71c05fdf 100644 --- a/EhPanda/View/Search/SearchRootView.swift +++ b/EhPanda/View/Search/SearchRootView.swift @@ -214,10 +214,9 @@ private struct QuickSearchWordsSection: View { } private var keywords: [WrappedKeyword] { - quickSearchWords.map { word in - .init(keyword: word.content, displayText: word.name) - } - .removeDuplicates() + quickSearchWords + .map({ .init(keyword: $0.content, displayText: $0.name) }) + .removeDuplicates() } var body: some View { @@ -245,7 +244,7 @@ private struct HistoryKeywordsSection: View { var body: some View { SubSection(title: L10n.Localizable.SearchView.Section.Title.recentlySearched, showAll: false) { DoubleVerticalKeywordsStack( - keywords: keywords.map({ WrappedKeyword(keyword: $0) }), + keywords: keywords.map(WrappedKeyword.init), searchAction: searchAction, removeAction: removeAction ) @@ -345,16 +344,23 @@ private struct KeywordCell: View { self.removeAction = removeAction } + var title: String { + wrappedKeyword.displayText.isEmpty ? wrappedKeyword.keyword : wrappedKeyword.displayText + } + var body: some View { HStack(spacing: 20) { Button { searchAction(wrappedKeyword.keyword) } label: { Image(systemSymbol: .magnifyingglass) - Text(wrappedKeyword.displayText ?? wrappedKeyword.keyword).lineLimit(1) - Spacer() + + Text(title) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) } .tint(.primary) + if removeAction != nil { Button { removeAction?(wrappedKeyword.keyword) @@ -400,7 +406,16 @@ private struct HistoryGalleriesSection: View { // MARK: Definition private struct WrappedKeyword: Hashable { let keyword: String - var displayText: String? + let displayText: String + + init(keyword: String, displayText: String) { + self.keyword = keyword + self.displayText = displayText + } + + init(keyword: String) { + self.init(keyword: keyword, displayText: .init()) + } } struct SearchRootView_Previews: PreviewProvider { From f05268defe6f32c763293e52411255f373586e8d Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 19 Oct 2025 15:41:43 +0800 Subject: [PATCH 25/40] Resolve reading setting empty toolbar item issue --- EhPanda/View/Reading/Support/ReadingViewExtensions.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EhPanda/View/Reading/Support/ReadingViewExtensions.swift b/EhPanda/View/Reading/Support/ReadingViewExtensions.swift index 4af25e53..736a7d2c 100644 --- a/EhPanda/View/Reading/Support/ReadingViewExtensions.swift +++ b/EhPanda/View/Reading/Support/ReadingViewExtensions.swift @@ -126,8 +126,8 @@ extension View { extension View { func readingSettingToolbar(dismissAction: @escaping () -> Void) -> some View { self.toolbar { - CustomToolbarItem(placement: .cancellationAction) { - if !DeviceUtil.isPad && DeviceUtil.isLandscape { + if !DeviceUtil.isPad && DeviceUtil.isLandscape { + CustomToolbarItem(placement: .cancellationAction) { Button(action: dismissAction) { Image(systemSymbol: .chevronDown) } From 989717a1692d721baa69d9df29d44d087e5df65f Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 19 Oct 2025 17:01:01 +0800 Subject: [PATCH 26/40] Resolve EhSetting page crash issue --- .../Setting/EhSetting/EhSettingView.swift | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView.swift b/EhPanda/View/Setting/EhSetting/EhSettingView.swift index 9c6f4643..52c641ad 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingView.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView.swift @@ -874,14 +874,17 @@ private struct ValuePicker: View { Slider( value: $value, in: range, - step: 1, - minimumValueLabel: Text(String(Int(range.lowerBound)) + unit) - .fontWeight(.medium) - .font(.callout), - maximumValueLabel: Text(String(Int(range.upperBound)) + unit) - .fontWeight(.medium) - .font(.callout), - label: EmptyView.init + label: EmptyView.init, + minimumValueLabel: { + Text(String(Int(range.lowerBound)) + unit) + .fontWeight(.medium) + .font(.callout) + }, + maximumValueLabel: { + Text(String(Int(range.upperBound)) + unit) + .fontWeight(.medium) + .font(.callout) + } ) } } From 3ba2bf6173c7bc6f44f987ca2f552b84a8ba6290 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 19 Oct 2025 17:02:11 +0800 Subject: [PATCH 27/40] Remove unnecessary textCase modifiers --- .../Setting/EhSetting/EhSettingView.swift | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView.swift b/EhPanda/View/Setting/EhSetting/EhSettingView.swift index 52c641ad..ed795441 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingView.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView.swift @@ -197,7 +197,6 @@ private struct EhProfileSection: View { .onChange(of: ehProfile) { _, newValue in performEhProfileAction(nil, nil, newValue.value) } - .textCase(nil) Section { SettingTextField(text: $editingProfileName, width: nil, alignment: .leading, background: .clear) @@ -243,7 +242,6 @@ private struct ImageLoadSettingsSection: View { } footer: { Text(ehSetting.loadThroughHathSetting.description) } - .textCase(nil) Section( L10n.Localizable.EhSettingView.Description.browsingCountry( @@ -259,7 +257,6 @@ private struct ImageLoadSettingsSection: View { } } } - .textCase(nil) } } @@ -285,7 +282,6 @@ private struct ImageSizeSettingsSection: View { .newlineBold() .appending(L10n.Localizable.EhSettingView.Description.imageResolution) } - .textCase(nil) if let useOriginalImagesBinding = Binding($ehSetting.useOriginalImages) { Section(L10n.Localizable.EhSettingView.Section.Title.originalImages) { @@ -294,7 +290,6 @@ private struct ImageSizeSettingsSection: View { isOn: useOriginalImagesBinding ) } - .textCase(nil) } Section(L10n.Localizable.EhSettingView.Description.imageSize) { @@ -310,7 +305,6 @@ private struct ImageSizeSettingsSection: View { value: $ehSetting.imageSizeHeight, range: 0...65535, unit: "px" ) } - .textCase(nil) } } @@ -336,7 +330,6 @@ private struct GalleryNameDisplaySection: View { .newlineBold() .appending(L10n.Localizable.EhSettingView.Description.galleryName) } - .textCase(nil) } } @@ -362,7 +355,6 @@ private struct ArchiverSettingsSection: View { .newlineBold() .appending(L10n.Localizable.EhSettingView.Description.archiverBehavior) } - .textCase(nil) } } @@ -386,7 +378,6 @@ private struct FrontPageSettingsSection: View { .newlineBold() .appending(L10n.Localizable.EhSettingView.Description.galleryCategory) } - .textCase(nil) Section(L10n.Localizable.EhSettingView.Description.displayMode) { Picker(L10n.Localizable.EhSettingView.Title.displayMode, selection: $ehSetting.displayMode) { @@ -397,7 +388,6 @@ private struct FrontPageSettingsSection: View { } .pickerStyle(.menu) } - .textCase(nil) Section(L10n.Localizable.EhSettingView.Section.Title.showSearchRangeIndicator) { Toggle( @@ -405,7 +395,6 @@ private struct FrontPageSettingsSection: View { isOn: $ehSetting.showSearchRangeIndicator ) } - .textCase(nil) } } @@ -428,7 +417,6 @@ private struct OptionalUIElementsSection: View { .newlineBold() .appending(L10n.Localizable.EhSettingView.Description.optionalUIElements) } - .textCase(nil) } } @@ -465,7 +453,6 @@ private struct FavoritesSection: View { .newlineBold() .appending(L10n.Localizable.EhSettingView.Description.favoriteCategories) } - .textCase(nil) Section(L10n.Localizable.EhSettingView.Description.favoritesSortOrder) { Picker( @@ -479,7 +466,6 @@ private struct FavoritesSection: View { } .pickerStyle(.menu) } - .textCase(nil) } } @@ -507,7 +493,6 @@ private struct RatingsSection: View { .newlineBold() .appending(L10n.Localizable.EhSettingView.Description.ratingsColor) } - .textCase(nil) } } @@ -530,7 +515,6 @@ private struct TagFilteringThresholdSection: View { .newlineBold() .appending(L10n.Localizable.EhSettingView.Description.tagFilteringThreshold) } - .textCase(nil) } } @@ -553,7 +537,6 @@ private struct TagWatchingThresholdSection: View { .newlineBold() .appending(L10n.Localizable.EhSettingView.Description.tagWatchingThreshold) } - .textCase(nil) } } @@ -575,7 +558,6 @@ private struct FilteredRemovalCountSection: View { Text(L10n.Localizable.EhSettingView.Section.Title.filteredRemovalCount).newlineBold() + Text(L10n.Localizable.EhSettingView.Description.filteredRemovalCount) } - .textCase(nil) } } @@ -631,7 +613,6 @@ private struct ExcludedLanguagesSection: View { .newlineBold() .appending(L10n.Localizable.EhSettingView.Description.excludedLanguages) } - .textCase(nil) } } @@ -712,7 +693,6 @@ private struct ExcludedUploadersSection: View { .localizedKey ) } - .textCase(nil) } } @@ -738,7 +718,6 @@ private struct SearchResultCountSection: View { .newlineBold() .appending(L10n.Localizable.EhSettingView.Description.resultCount) } - .textCase(nil) } } @@ -769,7 +748,6 @@ private struct ThumbnailSettingsSection: View { } footer: { Text(ehSetting.thumbnailLoadTiming.description) } - .textCase(nil) Section(L10n.Localizable.EhSettingView.Description.thumbnailConfiguration) { LabeledContent(L10n.Localizable.EhSettingView.Title.thumbnailSize) { @@ -798,7 +776,6 @@ private struct ThumbnailSettingsSection: View { .frame(width: 200) } } - .textCase(nil) } } @@ -823,7 +800,6 @@ private struct CoverScalingSection: View { .newlineBold() .appending(L10n.Localizable.EhSettingView.Description.coverScaleFactor) } - .textCase(nil) } } @@ -848,7 +824,6 @@ private struct ViewportOverrideSection: View { .newlineBold() .appending(L10n.Localizable.EhSettingView.Description.virtualWidth) } - .textCase(nil) } } @@ -921,7 +896,6 @@ private struct GalleryCommentsSection: View { } .pickerStyle(.menu) } - .textCase(nil) } } @@ -943,7 +917,6 @@ private struct GalleryTagsSection: View { } .pickerStyle(.menu) } - .textCase(nil) } } @@ -968,7 +941,6 @@ private struct GalleryPageThumbnailLabelingSection: View { } .pickerStyle(.menu) } - .textCase(nil) } } @@ -1007,7 +979,6 @@ private struct MultiplePageViewerSection: View { isOn: multiplePageViewerShowPaneBinding ) } - .textCase(nil) } } } From fd009f7c5e3e3959a12a6e50aca5fcdbabe2bd02 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 19 Oct 2025 17:26:23 +0800 Subject: [PATCH 28/40] Resolve search root title disappear issue --- EhPanda/View/Search/SearchRootView.swift | 8 +++++++- EhPanda/View/Setting/EhSetting/EhSettingView.swift | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/EhPanda/View/Search/SearchRootView.swift b/EhPanda/View/Search/SearchRootView.swift index 71c05fdf..e2cd9eb3 100644 --- a/EhPanda/View/Search/SearchRootView.swift +++ b/EhPanda/View/Search/SearchRootView.swift @@ -92,7 +92,13 @@ struct SearchRootView: View { .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } } else { - content + // Workaround: Prevent the title disappearing issue. + if store.historyKeywords.isEmpty && store.historyGalleries.isEmpty { + content + .navigationSubtitle(Text(" ")) + } else { + content + } } } } diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView.swift b/EhPanda/View/Setting/EhSetting/EhSettingView.swift index ed795441..b9a37a9a 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingView.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView.swift @@ -23,7 +23,7 @@ struct EhSettingView: View { // MARK: EhSettingView var body: some View { ZStack { - // workaround: Stay if-else approach + // Workaround: Stay if-else approach if store.loadingState == .loading || store.submittingState == .loading { LoadingView() .tint(nil) From 64a16c8f6f35fcaf7881fce1c537d5d623aab363 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 19 Oct 2025 17:43:22 +0800 Subject: [PATCH 29/40] Clean up ReadingView codes --- EhPanda/View/Reading/ReadingView.swift | 6 +- .../View/Reading/Support/ControlPanel.swift | 330 ++++++------------ 2 files changed, 102 insertions(+), 234 deletions(-) diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index 810a4161..492e1e20 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -141,8 +141,8 @@ private struct ReadingContentView: View { gestureCoordinator: gestureCoordinator, pageCoordinator: pageCoordinator, page: page - ) - } else { + ) + } else { HorizontalReadingView( store: store, setting: $setting, @@ -291,7 +291,7 @@ private struct ImprovedScrollView: View { ) } } - .onAppear { + .onAppear { scrollToCurrentPage(proxy: proxy) } } diff --git a/EhPanda/View/Reading/Support/ControlPanel.swift b/EhPanda/View/Reading/Support/ControlPanel.swift index e89323cf..a1b4580a 100644 --- a/EhPanda/View/Reading/Support/ControlPanel.swift +++ b/EhPanda/View/Reading/Support/ControlPanel.swift @@ -67,7 +67,9 @@ struct ControlPanel: View { retryAllFailedImagesAction: retryAllFailedImagesAction ) .offset(y: showsPanel ? 0 : -50) + Spacer() + if range.upperBound > range.lowerBound { LowerPanel( showsSliderPreview: $showsSliderPreview, @@ -119,197 +121,102 @@ private struct UpperPanel: View { var body: some View { HStack { // Liquid Glass Dismiss Button - if #available(iOS 26.0, *) { - Button(action: dismissAction) { - Image(systemSymbol: .xmark) - .font(.title2) - .foregroundColor(.primary) - .frame(width: 44, height: 44) - } - .glassEffect(.regular.interactive()) - .padding(.leading, 20) - } else { - Button(action: dismissAction) { - Image(systemSymbol: .xmark) - .font(.title2) - .foregroundColor(.primary) - .frame(width: 44, height: 44) - .background(Material.ultraThinMaterial) - .clipShape(Circle()) - } - .padding(.leading, 20) + Button(action: dismissAction) { + Image(systemSymbol: .xmark) + .font(.title2) + .foregroundColor(.primary) + .frame(width: 44, height: 44) } + .glassEffect(.regular.interactive()) Spacer() // Page Number Display in Liquid Glass Bubble - if #available(iOS 26.0, *) { - Text(title) - .bold() - .lineLimit(1) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .glassEffect() - } else { - Text(title) - .bold() - .lineLimit(1) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Material.thinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 16)) - } + Text(title) + .bold() + .lineLimit(1) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .glassEffect() Spacer() // Toolbar Grouped in Liquid Glass Container - if #available(iOS 26.0, *) { - HStack(spacing: 16) { - Button { - enablesLiveText.toggle() - } - label: { - Image(systemSymbol: .viewfinderCircle) - .symbolVariant(enablesLiveText ? .fill : .none) - .font(.title2) - } + HStack(spacing: 16) { + Button { + enablesLiveText.toggle() + } label: { + Image(systemSymbol: .viewfinderCircle) + .symbolVariant(enablesLiveText ? .fill : .none) + .font(.title2) + } - if DeviceUtil.isLandscape && setting.readingDirection != .vertical { - Menu { - Button { - setting.enablesDualPageMode.toggle() - } label: { - Text(L10n.Localizable.ReadingView.ToolbarItem.Title.dualPageMode) - if setting.enablesDualPageMode { - Image(systemSymbol: .checkmark) - } - } - Button { - setting.exceptCover.toggle() - } label: { - Text(L10n.Localizable.ReadingView.ToolbarItem.Title.exceptTheCover) - if setting.exceptCover { - Image(systemSymbol: .checkmark) - } - } - .disabled(!setting.enablesDualPageMode) + if DeviceUtil.isLandscape && setting.readingDirection != .vertical { + Menu { + Button { + setting.enablesDualPageMode.toggle() } label: { - Image(systemSymbol: .rectangleSplit2x1) - .symbolVariant(setting.enablesDualPageMode ? .fill : .none) - .font(.title2) + Text(L10n.Localizable.ReadingView.ToolbarItem.Title.dualPageMode) + if setting.enablesDualPageMode { + Image(systemSymbol: .checkmark) + } } - } - - Menu { - Text(L10n.Localizable.ReadingView.ToolbarItem.Title.autoPlay).foregroundColor(.secondary) - ForEach(AutoPlayPolicy.allCases) { policy in - Button { - autoPlayPolicy = policy - } label: { - Text(policy.value) - if autoPlayPolicy == policy { - Image(systemSymbol: .checkmark) - } + Button { + setting.exceptCover.toggle() + } label: { + Text(L10n.Localizable.ReadingView.ToolbarItem.Title.exceptTheCover) + if setting.exceptCover { + Image(systemSymbol: .checkmark) } } + .disabled(!setting.enablesDualPageMode) } label: { - Image(systemSymbol: .timer) + Image(systemSymbol: .rectangleSplit2x1) + .symbolVariant(setting.enablesDualPageMode ? .fill : .none) .font(.title2) } - .menuStyle(BorderlessButtonMenuStyle()) + } - ToolbarFeaturesMenu { - Button(action: retryAllFailedImagesAction) { - Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) - Text(L10n.Localizable.ReadingView.ToolbarItem.Button.retryAllFailedImages) - } - Button(action: reloadAllImagesAction) { - Image(systemSymbol: .arrowCounterclockwise) - Text(L10n.Localizable.ReadingView.ToolbarItem.Button.reloadAllImages) - } - Button(action: navigateSettingAction) { - Image(systemSymbol: .gear) - Text(L10n.Localizable.ReadingView.ToolbarItem.Button.readingSetting) + Menu { + Text(L10n.Localizable.ReadingView.ToolbarItem.Title.autoPlay).foregroundColor(.secondary) + ForEach(AutoPlayPolicy.allCases) { policy in + Button { + autoPlayPolicy = policy + } label: { + Text(policy.value) + if autoPlayPolicy == policy { + Image(systemSymbol: .checkmark) + } } } - .font(.title2) - .menuStyle(BorderlessButtonMenuStyle()) + } label: { + Image(systemSymbol: .timer) + .font(.title2) } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .glassEffect(.regular.interactive()) - .padding(.trailing, 20) - } else { - HStack(spacing: 20) { - Button { - enablesLiveText.toggle() - } label: { - Image(systemSymbol: .viewfinderCircle) - .symbolVariant(enablesLiveText ? .fill : .none) - } - if DeviceUtil.isLandscape && setting.readingDirection != .vertical { - Menu { - Button { - setting.enablesDualPageMode.toggle() - } label: { - Text(L10n.Localizable.ReadingView.ToolbarItem.Title.dualPageMode) - if setting.enablesDualPageMode { - Image(systemSymbol: .checkmark) - } - } - Button { - setting.exceptCover.toggle() - } label: { - Text(L10n.Localizable.ReadingView.ToolbarItem.Title.exceptTheCover) - if setting.exceptCover { - Image(systemSymbol: .checkmark) - } - } - .disabled(!setting.enablesDualPageMode) - } label: { - Image(systemSymbol: .rectangleSplit2x1) - .symbolVariant(setting.enablesDualPageMode ? .fill : .none) - } + .buttonStyle(.borderless) + + ToolbarFeaturesMenu { + Button(action: retryAllFailedImagesAction) { + Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) + Text(L10n.Localizable.ReadingView.ToolbarItem.Button.retryAllFailedImages) } - Menu { - Text(L10n.Localizable.ReadingView.ToolbarItem.Title.autoPlay).foregroundColor(.secondary) - ForEach(AutoPlayPolicy.allCases) { policy in - Button { - autoPlayPolicy = policy - } label: { - Text(policy.value) - if autoPlayPolicy == policy { - Image(systemSymbol: .checkmark) - } - } - } - } label: { - Image(systemSymbol: .timer) + Button(action: reloadAllImagesAction) { + Image(systemSymbol: .arrowCounterclockwise) + Text(L10n.Localizable.ReadingView.ToolbarItem.Button.reloadAllImages) } - ToolbarFeaturesMenu { - Button(action: retryAllFailedImagesAction) { - Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) - Text(L10n.Localizable.ReadingView.ToolbarItem.Button.retryAllFailedImages) - } - Button(action: reloadAllImagesAction) { - Image(systemSymbol: .arrowCounterclockwise) - Text(L10n.Localizable.ReadingView.ToolbarItem.Button.reloadAllImages) - } - Button(action: navigateSettingAction) { - Image(systemSymbol: .gear) - Text(L10n.Localizable.ReadingView.ToolbarItem.Button.readingSetting) - } + Button(action: navigateSettingAction) { + Image(systemSymbol: .gear) + Text(L10n.Localizable.ReadingView.ToolbarItem.Button.readingSetting) } - .padding(.trailing, 20) } + .buttonStyle(.borderless) .font(.title2) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Material.thinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .padding(.trailing, 20) } + .padding(.vertical, 8) + .padding(.horizontal, 16) + .glassEffect(.regular.interactive()) } + .padding(.horizontal, 20) } } @@ -343,79 +250,40 @@ private struct LowerPanel: View { var body: some View { VStack(spacing: 30) { // Dismiss Button - if #available(iOS 26.0, *) { - Button(action: dismissAction) { - Image(systemSymbol: .xmark) - .foregroundColor(.primary) - .font(.title2) - .frame(width: 44, height: 44) - } - .glassEffect(.regular.interactive()) - .gesture(dismissGesture) - .opacity(showsSliderPreview ? 0 : 1) - } else { - Button(action: dismissAction) { - Image(systemSymbol: .xmark) - .foregroundColor(.primary) - .font(.title2) - .frame(width: 44, height: 44) - .background(Material.ultraThinMaterial) - .clipShape(Circle()) - } - .gesture(dismissGesture) - .opacity(showsSliderPreview ? 0 : 1) + Button(action: dismissAction) { + Image(systemSymbol: .xmark) + .foregroundColor(.primary) + .font(.title2) + .frame(width: 44, height: 44) } + .glassEffect(.regular.interactive()) + .gesture(dismissGesture) + .opacity(showsSliderPreview ? 0 : 1) // Slider in Liquid Glass Bubble - if #available(iOS 26.0, *) { - VStack(spacing: 0) { - SliderPreivew( - showsSliderPreview: $showsSliderPreview, - sliderValue: $sliderValue, previewURLs: previewURLs, range: range, - isReversed: isReversed, fetchPreviewURLsAction: fetchPreviewURLsAction - ) - VStack { - HStack { - Text(isReversed ? "\(Int(range.upperBound))" : "\(Int(range.lowerBound))") - .fontWeight(.medium).font(.caption).padding() - Slider( - value: $sliderValue, in: range, step: 1, - onEditingChanged: { showsSliderPreview = $0 } - ) - // wtaf is happening here? - .frame(width: DeviceUtil.windowW * 0.6) - .rotationEffect(.init(degrees: isReversed ? 180 : 0)) - Text(isReversed ? "\(Int(range.lowerBound))" : "\(Int(range.upperBound))") - .fontWeight(.medium).font(.caption).padding() - } - .padding(.horizontal) - .glassEffect() - } - } - } else { - VStack(spacing: 0) { - SliderPreivew( - showsSliderPreview: $showsSliderPreview, - sliderValue: $sliderValue, previewURLs: previewURLs, range: range, - isReversed: isReversed, fetchPreviewURLsAction: fetchPreviewURLsAction - ) - VStack { - HStack { - Text(isReversed ? "\(Int(range.upperBound))" : "\(Int(range.lowerBound))") - .fontWeight(.medium).font(.caption).padding() - Slider( - value: $sliderValue, in: range, step: 1, - onEditingChanged: { showsSliderPreview = $0 } - ) - .rotationEffect(.init(degrees: isReversed ? 180 : 0)) - Text(isReversed ? "\(Int(range.lowerBound))" : "\(Int(range.upperBound))") - .fontWeight(.medium).font(.caption).padding() - } - .padding(.horizontal).padding(.bottom) + VStack(spacing: 0) { + SliderPreivew( + showsSliderPreview: $showsSliderPreview, + sliderValue: $sliderValue, previewURLs: previewURLs, range: range, + isReversed: isReversed, fetchPreviewURLsAction: fetchPreviewURLsAction + ) + VStack { + HStack { + Text(isReversed ? "\(Int(range.upperBound))" : "\(Int(range.lowerBound))") + .fontWeight(.medium).font(.caption).padding() + Slider( + value: $sliderValue, in: range, step: 1, + onEditingChanged: { showsSliderPreview = $0 } + ) + // wtaf is happening here? + .frame(width: DeviceUtil.windowW * 0.6) + .rotationEffect(.init(degrees: isReversed ? 180 : 0)) + Text(isReversed ? "\(Int(range.lowerBound))" : "\(Int(range.upperBound))") + .fontWeight(.medium).font(.caption).padding() } + .padding(.horizontal) + .glassEffect() } - .background(Material.thinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 20)) } } } From c038a0ff7c52d65e1451d0df8436b96a6c19902c Mon Sep 17 00:00:00 2001 From: Chihchy Date: Sun, 19 Oct 2025 18:57:52 +0800 Subject: [PATCH 30/40] Improve slide preview layout --- .../View/Reading/Support/ControlPanel.swift | 78 ++++++++++++------- 1 file changed, 48 insertions(+), 30 deletions(-) diff --git a/EhPanda/View/Reading/Support/ControlPanel.swift b/EhPanda/View/Reading/Support/ControlPanel.swift index a1b4580a..56f4f7e7 100644 --- a/EhPanda/View/Reading/Support/ControlPanel.swift +++ b/EhPanda/View/Reading/Support/ControlPanel.swift @@ -264,27 +264,37 @@ private struct LowerPanel: View { VStack(spacing: 0) { SliderPreivew( showsSliderPreview: $showsSliderPreview, - sliderValue: $sliderValue, previewURLs: previewURLs, range: range, - isReversed: isReversed, fetchPreviewURLsAction: fetchPreviewURLsAction + sliderValue: $sliderValue, + previewURLs: previewURLs, + range: range, + isReversed: isReversed, + fetchPreviewURLsAction: fetchPreviewURLsAction ) - VStack { - HStack { - Text(isReversed ? "\(Int(range.upperBound))" : "\(Int(range.lowerBound))") - .fontWeight(.medium).font(.caption).padding() - Slider( - value: $sliderValue, in: range, step: 1, - onEditingChanged: { showsSliderPreview = $0 } - ) - // wtaf is happening here? - .frame(width: DeviceUtil.windowW * 0.6) - .rotationEffect(.init(degrees: isReversed ? 180 : 0)) - Text(isReversed ? "\(Int(range.lowerBound))" : "\(Int(range.upperBound))") - .fontWeight(.medium).font(.caption).padding() - } - .padding(.horizontal) - .glassEffect() + + HStack { + Text(isReversed ? "\(Int(range.upperBound))" : "\(Int(range.lowerBound))") + .fontWeight(.medium) + .font(.caption) + .padding() + + Slider( + value: $sliderValue, + in: range, + step: 1, + onEditingChanged: { showsSliderPreview = $0 } + ) + // wtaf is happening here? + .frame(width: DeviceUtil.windowW * 0.6) + .rotationEffect(.init(degrees: isReversed ? 180 : 0)) + + Text(isReversed ? "\(Int(range.lowerBound))" : "\(Int(range.upperBound))") + .fontWeight(.medium) + .font(.caption) + .padding() } } + .glassEffect(in: .rect(cornerRadius: 16)) + .padding(.horizontal, SliderPreivew.outerPadding) } } } @@ -298,10 +308,15 @@ private struct SliderPreivew: View { private let isReversed: Bool private let fetchPreviewURLsAction: (Int) -> Void + static let outerPadding: CGFloat = 8 + init( - showsSliderPreview: Binding, sliderValue: Binding, - previewURLs: [Int: URL], range: ClosedRange, - isReversed: Bool, fetchPreviewURLsAction: @escaping (Int) -> Void + showsSliderPreview: Binding, + sliderValue: Binding, + previewURLs: [Int: URL], + range: ClosedRange, + isReversed: Bool, + fetchPreviewURLsAction: @escaping (Int) -> Void ) { _showsSliderPreview = showsSliderPreview _sliderValue = sliderValue @@ -317,15 +332,15 @@ private struct SliderPreivew: View { let (url, modifier) = PreviewResolver.getPreviewConfigs(originalURL: previewURLs[index]) VStack { KFImage.url(url, cacheKey: previewURLs[index]?.absoluteString) - .placeholder { - Placeholder(style: .activity( - ratio: Defaults.ImageSize.previewAspect - )) - } + .placeholder({ Placeholder(style: .activity(ratio: Defaults.ImageSize.previewAspect)) }) .fade(duration: 0.25) - .imageModifier(modifier).resizable().scaledToFit() + .imageModifier(modifier) + .resizable() + .scaledToFit() .frame(width: previewWidth, height: showsSliderPreview ? previewHeight : 0) - Text("\(index)").font(DeviceUtil.isPadWidth ? .callout : .caption) + + Text("\(index)") + .font(DeviceUtil.isPadWidth ? .callout : .caption) .foregroundColor(index == Int(sliderValue) ? .accentColor : .secondary) } .onAppear { @@ -336,7 +351,9 @@ private struct SliderPreivew: View { .opacity(checkIndex(index) ? 1 : 0) } } - .opacity(showsSliderPreview ? 1 : 0).padding(.vertical, verticalPadding) + .opacity(showsSliderPreview ? 1 : 0) + .padding(.vertical, verticalPadding) + .padding(.horizontal, horizontalPadding) .frame(height: showsSliderPreview ? previewHeight + verticalPadding * 2 : 0) } } @@ -345,6 +362,7 @@ private extension SliderPreivew { var verticalPadding: CGFloat { DeviceUtil.isPadWidth ? 30 : 20 } + var horizontalPadding: CGFloat { verticalPadding * 0.5 } var previewsCount: Int { DeviceUtil.isPadWidth ? DeviceUtil.isLandscape ? 7 : 5 : 3 } @@ -365,7 +383,7 @@ private extension SliderPreivew { var previewWidth: CGFloat { guard previewsCount > 0 else { return 0 } let count = CGFloat(previewsCount) - let spacing = (count + 1) * previewSpacing + let spacing = (count + 1) * previewSpacing + horizontalPadding * 2 + Self.outerPadding * 2 return (DeviceUtil.windowW - spacing) / count } func checkIndex(_ index: Int) -> Bool { From 54cce238d37e8a5a758ba40b995a1275422dafaa Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 20 Oct 2025 22:35:06 +0800 Subject: [PATCH 31/40] Resolve reading control panel gesture issue --- .../View/Reading/Support/ControlPanel.swift | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/EhPanda/View/Reading/Support/ControlPanel.swift b/EhPanda/View/Reading/Support/ControlPanel.swift index 56f4f7e7..ecb04484 100644 --- a/EhPanda/View/Reading/Support/ControlPanel.swift +++ b/EhPanda/View/Reading/Support/ControlPanel.swift @@ -25,9 +25,15 @@ struct ControlPanel: View { private let fetchPreviewURLsAction: (Int) -> Void init( - showsPanel: Binding, showsSliderPreview: Binding, sliderValue: Binding, - setting: Binding, enablesLiveText: Binding, autoPlayPolicy: Binding, - range: ClosedRange, previewURLs: [Int: URL], dismissGesture: G, + showsPanel: Binding, + showsSliderPreview: Binding, + sliderValue: Binding, + setting: Binding, + enablesLiveText: Binding, + autoPlayPolicy: Binding, + range: ClosedRange, + previewURLs: [Int: URL], + dismissGesture: G, dismissAction: @escaping () -> Void, navigateSettingAction: @escaping () -> Void, reloadAllImagesAction: @escaping () -> Void, @@ -73,9 +79,12 @@ struct ControlPanel: View { if range.upperBound > range.lowerBound { LowerPanel( showsSliderPreview: $showsSliderPreview, - sliderValue: $sliderValue, previewURLs: previewURLs, range: range, + sliderValue: $sliderValue, + previewURLs: previewURLs, + range: range, isReversed: setting.readingDirection == .rightToLeft, - dismissGesture: dismissGesture, dismissAction: dismissAction, + dismissGesture: dismissGesture, + dismissAction: dismissAction, fetchPreviewURLsAction: fetchPreviewURLsAction ) .animation(.default, value: showsSliderPreview) @@ -280,12 +289,15 @@ private struct LowerPanel: View { Slider( value: $sliderValue, in: range, - step: 1, - onEditingChanged: { showsSliderPreview = $0 } + onEditingChanged: { if !$0 { showsSliderPreview = false } } ) // wtaf is happening here? .frame(width: DeviceUtil.windowW * 0.6) .rotationEffect(.init(degrees: isReversed ? 180 : 0)) + .simultaneousGesture( + LongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity) + .onChanged({ if $0 { showsSliderPreview = true } }) + ) Text(isReversed ? "\(Int(range.lowerBound))" : "\(Int(range.upperBound))") .fontWeight(.medium) From 594c196a934d473302ac27ca04c7975562561cb8 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Mon, 20 Oct 2025 23:52:08 +0800 Subject: [PATCH 32/40] Resolve pager index & slider value inconsistency --- EhPanda/View/Reading/ReadingView.swift | 10 ++++------ EhPanda/View/Reading/Support/PageCoordinator.swift | 2 +- .../View/Reading/Support/ReadingViewExtensions.swift | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index 492e1e20..e55266a4 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -385,9 +385,9 @@ private struct ImprovedScrollView: View { let targetId = pageIndex + 1 if isScrollEnabled { - withAnimation(.easeInOut(duration: 0.3)) { - proxy.scrollTo(targetId, anchor: .center) - } + // Do not apply animation here unless you can let the method + // `updateCurrentVisibleIndex` stop screwing up the slider value. + proxy.scrollTo(targetId, anchor: .center) } else { // Store target for when scrolling is re-enabled scrollTarget = targetId @@ -433,9 +433,7 @@ private struct ReadingControlsOverlay: View { enablesLiveText: $viewModel.enablesLiveText, autoPlayPolicy: .init( get: { viewModel.autoPlayPolicy }, - set: { viewModel.setAutoPlayPolicy($0, pageUpdater: { - page.update(.next) - }) } + set: { viewModel.setAutoPlayPolicy($0, pageUpdater: { page.update(.next) }) } ), range: 1...Float(store.gallery.pageCount), previewURLs: store.previewURLs, diff --git a/EhPanda/View/Reading/Support/PageCoordinator.swift b/EhPanda/View/Reading/Support/PageCoordinator.swift index fa2d98f1..4aae8e31 100644 --- a/EhPanda/View/Reading/Support/PageCoordinator.swift +++ b/EhPanda/View/Reading/Support/PageCoordinator.swift @@ -209,7 +209,7 @@ final class PageCoordinator: ObservableObject { // MARK: - Private Methods private func handleSliderValueChange(_ newValue: Float) { - Logger.info("Handle slider value change", context: [ + Logger.debug("Handle slider value change", context: [ "newValue": newValue, "pageCount": pageCount ]) diff --git a/EhPanda/View/Reading/Support/ReadingViewExtensions.swift b/EhPanda/View/Reading/Support/ReadingViewExtensions.swift index 736a7d2c..55905350 100644 --- a/EhPanda/View/Reading/Support/ReadingViewExtensions.swift +++ b/EhPanda/View/Reading/Support/ReadingViewExtensions.swift @@ -236,7 +236,7 @@ extension View { pageCoordinator: PageCoordinator, setting: Setting ) { - Logger.info("Slider value changed", context: ["sliderValue": newValue]) + Logger.debug("Slider value changed", context: ["sliderValue": newValue]) if !showsSliderPreview { let pagerIndex = pageCoordinator.mapToPager(index: Int(newValue), setting: setting) From ef7395de6f496e6d7384bcb169b739bd6195f766 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 22 Oct 2025 22:12:25 +0800 Subject: [PATCH 33/40] Too many issues... reverting changes --- EhPanda.xcodeproj/project.pbxproj | 36 +- EhPanda/View/Reading/ReadingReducer.swift | 1251 ++++++----------- EhPanda/View/Reading/ReadingView.swift | 890 +++++++----- .../View/Reading/Support/AdvancedList.swift | 182 +-- .../Reading/Support/AutoPlayHandler.swift | 33 + .../View/Reading/Support/ControlPanel.swift | 63 +- .../Reading/Support/GestureCoordinator.swift | 372 ----- .../View/Reading/Support/GestureHandler.swift | 129 ++ .../View/Reading/Support/ImageStackView.swift | 355 ----- .../Reading/Support/LiveTextHandler.swift | 189 +++ .../Reading/Support/PageCoordinator.swift | 295 ---- .../View/Reading/Support/PageHandler.swift | 38 + .../Support/ReadingViewExtensions.swift | 444 ------ .../Reading/Support/ReadingViewModel.swift | 319 ----- 14 files changed, 1409 insertions(+), 3187 deletions(-) create mode 100644 EhPanda/View/Reading/Support/AutoPlayHandler.swift delete mode 100644 EhPanda/View/Reading/Support/GestureCoordinator.swift create mode 100644 EhPanda/View/Reading/Support/GestureHandler.swift delete mode 100644 EhPanda/View/Reading/Support/ImageStackView.swift create mode 100644 EhPanda/View/Reading/Support/LiveTextHandler.swift delete mode 100644 EhPanda/View/Reading/Support/PageCoordinator.swift create mode 100644 EhPanda/View/Reading/Support/PageHandler.swift delete mode 100644 EhPanda/View/Reading/Support/ReadingViewExtensions.swift delete mode 100644 EhPanda/View/Reading/Support/ReadingViewModel.swift diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index 926fd24d..38e9dc45 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -7,11 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 145E7E3E2E36DE6D00822CB0 /* ReadingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E7E3D2E36DE6D00822CB0 /* ReadingViewModel.swift */; }; - 145E7E3F2E36DE6D00822CB0 /* GestureCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E7E382E36DE6D00822CB0 /* GestureCoordinator.swift */; }; - 145E7E412E36DE6D00822CB0 /* PageCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E7E3A2E36DE6D00822CB0 /* PageCoordinator.swift */; }; - 145E7E422E36DE6D00822CB0 /* ReadingViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E7E3C2E36DE6D00822CB0 /* ReadingViewExtensions.swift */; }; - 145E7E432E36DE6D00822CB0 /* ImageStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E7E392E36DE6D00822CB0 /* ImageStackView.swift */; }; AB0929B6277F043D00F107CA /* AccountSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929B5277F043D00F107CA /* AccountSettingReducer.swift */; }; AB0929BE2780032400F107CA /* EhSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BD2780032400F107CA /* EhSettingReducer.swift */; }; AB0929C027805A8200F107CA /* LoginReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BF27805A8200F107CA /* LoginReducer.swift */; }; @@ -281,6 +276,10 @@ EA0C925E2C3EB49500D211F6 /* README.jpn.md in Resources */ = {isa = PBXBuildFile; fileRef = EA0C92582C3EB49500D211F6 /* README.jpn.md */; }; EA2E2E7F2A1F7E500038A261 /* SettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2E2E7E2A1F7E500038A261 /* SettingReducer.swift */; }; EA2E2E822A1FA1060038A261 /* SearchReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2E2E812A1FA1050038A261 /* SearchReducer.swift */; }; + EA5AA4A72EA9149E00BC2B5C /* PageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5AA4A62EA9149E00BC2B5C /* PageHandler.swift */; }; + EA5AA4A82EA9149E00BC2B5C /* LiveTextHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5AA4A52EA9149E00BC2B5C /* LiveTextHandler.swift */; }; + EA5AA4A92EA9149E00BC2B5C /* GestureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5AA4A42EA9149E00BC2B5C /* GestureHandler.swift */; }; + EA5AA4AA2EA9149E00BC2B5C /* AutoPlayHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5AA4A32EA9149E00BC2B5C /* AutoPlayHandler.swift */; }; EA698C032CCDD2FB0058BC19 /* EquatableVoid.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA698C022CCDD2FB0058BC19 /* EquatableVoid.swift */; }; EA698C092CCDE7090058BC19 /* IdentifiableBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA698C082CCDE7050058BC19 /* IdentifiableBox.swift */; }; EAE63E2129E2A6330048C601 /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = EAE63E2029E2A6330048C601 /* SwiftyBeaver */; }; @@ -318,11 +317,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 145E7E382E36DE6D00822CB0 /* GestureCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureCoordinator.swift; sourceTree = ""; }; - 145E7E392E36DE6D00822CB0 /* ImageStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageStackView.swift; sourceTree = ""; }; - 145E7E3A2E36DE6D00822CB0 /* PageCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageCoordinator.swift; sourceTree = ""; }; - 145E7E3C2E36DE6D00822CB0 /* ReadingViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingViewExtensions.swift; sourceTree = ""; }; - 145E7E3D2E36DE6D00822CB0 /* ReadingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingViewModel.swift; sourceTree = ""; }; AB0929B5277F043D00F107CA /* AccountSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingReducer.swift; sourceTree = ""; }; AB0929BD2780032400F107CA /* EhSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSettingReducer.swift; sourceTree = ""; }; AB0929BF27805A8200F107CA /* LoginReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginReducer.swift; sourceTree = ""; }; @@ -608,6 +602,10 @@ EA0C92582C3EB49500D211F6 /* README.jpn.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; name = README.jpn.md; path = READMEs/README.jpn.md; sourceTree = ""; }; EA2E2E7E2A1F7E500038A261 /* SettingReducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingReducer.swift; sourceTree = ""; }; EA2E2E812A1FA1050038A261 /* SearchReducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchReducer.swift; sourceTree = ""; }; + EA5AA4A32EA9149E00BC2B5C /* AutoPlayHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPlayHandler.swift; sourceTree = ""; }; + EA5AA4A42EA9149E00BC2B5C /* GestureHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureHandler.swift; sourceTree = ""; }; + EA5AA4A52EA9149E00BC2B5C /* LiveTextHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTextHandler.swift; sourceTree = ""; }; + EA5AA4A62EA9149E00BC2B5C /* PageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHandler.swift; sourceTree = ""; }; EA698C022CCDD2FB0058BC19 /* EquatableVoid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EquatableVoid.swift; sourceTree = ""; }; EA698C082CCDE7050058BC19 /* IdentifiableBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifiableBox.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -712,11 +710,10 @@ AB24C561276757A30085C33A /* Support */ = { isa = PBXGroup; children = ( - 145E7E382E36DE6D00822CB0 /* GestureCoordinator.swift */, - 145E7E392E36DE6D00822CB0 /* ImageStackView.swift */, - 145E7E3A2E36DE6D00822CB0 /* PageCoordinator.swift */, - 145E7E3C2E36DE6D00822CB0 /* ReadingViewExtensions.swift */, - 145E7E3D2E36DE6D00822CB0 /* ReadingViewModel.swift */, + EA5AA4A32EA9149E00BC2B5C /* AutoPlayHandler.swift */, + EA5AA4A42EA9149E00BC2B5C /* GestureHandler.swift */, + EA5AA4A52EA9149E00BC2B5C /* LiveTextHandler.swift */, + EA5AA4A62EA9149E00BC2B5C /* PageHandler.swift */, ABC732C627B90F0900D47DA9 /* LiveTextView.swift */, AB69CB8126B3DAF400699359 /* ControlPanel.swift */, AB69CB7F26B3DABC00699359 /* AdvancedList.swift */, @@ -1911,11 +1908,6 @@ AB706F862789AD490025A48A /* ToplistsReducer.swift in Sources */, AB7BF2CC27A96A3C001865A3 /* GalleryTorrent.swift in Sources */, ABF45AEA25F3313D00ECB568 /* Placeholder.swift in Sources */, - 145E7E3E2E36DE6D00822CB0 /* ReadingViewModel.swift in Sources */, - 145E7E3F2E36DE6D00822CB0 /* GestureCoordinator.swift in Sources */, - 145E7E412E36DE6D00822CB0 /* PageCoordinator.swift in Sources */, - 145E7E422E36DE6D00822CB0 /* ReadingViewExtensions.swift in Sources */, - 145E7E432E36DE6D00822CB0 /* ImageStackView.swift in Sources */, ABD4032826B7967F00001B8C /* CategoryView.swift in Sources */, ABC681F326898D46007BBD69 /* Model.xcdatamodeld in Sources */, ABBB266627977C2A007B6149 /* ArchivesReducer.swift in Sources */, @@ -2004,6 +1996,10 @@ ABBB2638278FBD2F007B6149 /* SwiftUINavigation_Extension.swift in Sources */, AB10117E26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift in Sources */, AB26F59627ACCA1800AB3468 /* AppEnv.swift in Sources */, + EA5AA4A72EA9149E00BC2B5C /* PageHandler.swift in Sources */, + EA5AA4A82EA9149E00BC2B5C /* LiveTextHandler.swift in Sources */, + EA5AA4A92EA9149E00BC2B5C /* GestureHandler.swift in Sources */, + EA5AA4AA2EA9149E00BC2B5C /* AutoPlayHandler.swift in Sources */, ABF45AE525F3313D00ECB568 /* PostCommentView.swift in Sources */, AB358313269D7E89009466A5 /* DFRequest.swift in Sources */, AB706F90278A5F680025A48A /* AppDelegateClient.swift in Sources */, diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index c64533a2..a0a0fd3f 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -7,11 +7,8 @@ import SwiftUI import TTProgressHUD import ComposableArchitecture -// MARK: - Reading Reducer @Reducer struct ReadingReducer { - - // MARK: - Route @CasePathable enum Route: Equatable { case hud @@ -19,27 +16,25 @@ struct ReadingReducer { case readingSetting(EquatableVoid = .init()) } - // MARK: - Share Item enum ShareItem: Equatable { - case data(Data) - case image(UIImage) - var associatedValue: Any { switch self { - case .data(let data): return data - case .image(let image): return image + case .data(let data): + return data + case .image(let image): + return image } } + case data(Data) + case image(UIImage) } - // MARK: - Image Action enum ImageAction { case copy(Bool) case save(Bool) case share(Bool) } - // MARK: - Cancel IDs private enum CancelID: CaseIterable { case fetchImage case fetchDatabaseInfos @@ -51,63 +46,100 @@ struct ReadingReducer { case fetchMPVImageURL } - // MARK: - State @ObservableState struct State: Equatable { - // MARK: - Navigation & UI var route: Route? - var showsPanel = false - var showsSliderPreview = false - var hudConfig: TTProgressHUDConfig = .loading - var forceRefreshID: UUID = .init() - - // MARK: - Gallery Data var gallery: Gallery = .empty var galleryDetail: GalleryDetail? + var readingProgress: Int = .zero + var forceRefreshID: UUID = .init() + var hudConfig: TTProgressHUDConfig = .loading - // MARK: - Loading States var webImageLoadSuccessIndices = Set() var imageURLLoadingStates = [Int: LoadingState]() var previewLoadingStates = [Int: LoadingState]() var databaseLoadingState: LoadingState = .loading - - // MARK: - Preview Configuration var previewConfig: PreviewConfig = .normal(rows: 4) - // MARK: - URL Storage var previewURLs = [Int: URL]() + var thumbnailURLs = [Int: URL]() var imageURLs = [Int: URL]() var originalImageURLs = [Int: URL]() - // MARK: - MPV Support var mpvKey: String? var mpvImageKeys = [Int: String]() var mpvSkipServerIdentifiers = [Int: String]() + + var showsPanel = false + var showsSliderPreview = false + + // Update + func update(stored: inout [Int: T], new: [Int: T], replaceExisting: Bool = true) { + guard !new.isEmpty else { return } + stored = stored.merging(new, uniquingKeysWith: { stored, new in replaceExisting ? new : stored }) + } + mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { + update(stored: &self.previewURLs, new: previewURLs) + } + mutating func updateThumbnailURLs(_ thumbnailURLs: [Int: URL]) { + update(stored: &self.thumbnailURLs, new: thumbnailURLs) + } + mutating func updateImageURLs(_ imageURLs: [Int: URL], _ originalImageURLs: [Int: URL]) { + update(stored: &self.imageURLs, new: imageURLs) + update(stored: &self.originalImageURLs, new: originalImageURLs) + } + + // Image + func containerDataSource(setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> [Int] { + let defaultData = Array(1...gallery.pageCount) + guard isLandscape && setting.enablesDualPageMode + && setting.readingDirection != .vertical + else { return defaultData } + + let data = setting.exceptCover + ? [1] + Array(stride(from: 2, through: gallery.pageCount, by: 2)) + : Array(stride(from: 1, through: gallery.pageCount, by: 2)) + + return data + } + func imageContainerConfigs( + index: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape + ) -> ImageStackConfig { + let direction = setting.readingDirection + let isReversed = direction == .rightToLeft + let isFirstSingle = setting.exceptCover + let isFirstPageAndSingle = index == 1 && isFirstSingle + let isDualPage = isLandscape && setting.enablesDualPageMode && direction != .vertical + let firstIndex = isDualPage && isReversed && !isFirstPageAndSingle ? index + 1 : index + let secondIndex = firstIndex + (isReversed ? -1 : 1) + let isValidFirstRange = firstIndex >= 1 && firstIndex <= gallery.pageCount + let isValidSecondRange = isFirstSingle + ? secondIndex >= 2 && secondIndex <= gallery.pageCount + : secondIndex >= 1 && secondIndex <= gallery.pageCount + return .init( + firstIndex: firstIndex, secondIndex: secondIndex, isFirstAvailable: isValidFirstRange, + isSecondAvailable: !isFirstPageAndSingle && isValidSecondRange && isDualPage + ) + } } - // MARK: - Action enum Action: BindableAction { - // MARK: - Binding & Navigation case binding(BindingAction) case setNavigation(Route?) + case toggleShowsPanel + case setOrientationPortrait(Bool) case onPerformDismiss case onAppear(String, Bool) - case teardown - - // MARK: - Orientation - case setOrientationPortrait(Bool) - // MARK: - Web Image Actions case onWebImageRetry(Int) case onWebImageSucceeded(Int) case onWebImageFailed(Int) case reloadAllWebImages case retryAllFailedWebImages - // MARK: - Image Actions case copyImage(URL) case saveImage(URL) case saveImageDone(Bool) @@ -115,43 +147,35 @@ struct ReadingReducer { case fetchImage(ImageAction, URL) case fetchImageDone(ImageAction, Result) - // MARK: - Data Synchronization case syncReadingProgress(Int) case syncPreviewURLs([Int: URL]) case syncThumbnailURLs([Int: URL]) case syncImageURLs([Int: URL], [Int: URL]) - // MARK: - Database Operations + case teardown case fetchDatabaseInfos(String) case fetchDatabaseInfosDone(GalleryState) - // MARK: - Preview Operations case fetchPreviewURLs(Int) case fetchPreviewURLsDone(Int, Result<[Int: URL], AppError>) - // MARK: - Image URL Operations case fetchImageURLs(Int) case refetchImageURLs(Int) case prefetchImages(Int, Int) - // MARK: - Thumbnail Operations case fetchThumbnailURLs(Int) case fetchThumbnailURLsDone(Int, Result<[Int: URL], AppError>) - - // MARK: - Normal Image Operations case fetchNormalImageURLs(Int, [Int: URL]) case fetchNormalImageURLsDone(Int, Result<([Int: URL], [Int: URL]), AppError>) case refetchNormalImageURLs(Int) case refetchNormalImageURLsDone(Int, Result<([Int: URL], HTTPURLResponse?), AppError>) - // MARK: - MPV Operations case fetchMPVKeys(Int, URL) case fetchMPVKeysDone(Int, Result<(String, [Int: String]), AppError>) case fetchMPVImageURL(Int, Bool) case fetchMPVImageURLDone(Int, Result<(URL, URL?, String), AppError>) } - // MARK: - Dependencies @Dependency(\.appDelegateClient) private var appDelegateClient @Dependency(\.clipboardClient) private var clipboardClient @Dependency(\.databaseClient) private var databaseClient @@ -161,870 +185,461 @@ struct ReadingReducer { @Dependency(\.imageClient) private var imageClient @Dependency(\.urlClient) private var urlClient - // MARK: - Body var body: some Reducer { BindingReducer() .onChange(of: \.showsSliderPreview) { _, _ in - Reduce({ _, _ in - .run(operation: { _ in - hapticsClient.generateFeedback(.soft) - }) - }) + Reduce({ _, _ in .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) }) } Reduce { state, action in switch action { - // MARK: - Basic Actions case .binding: return .none case .setNavigation(let route): - return handleSetNavigation(&state, route: route) + state.route = route + return .none case .toggleShowsPanel: - return handleToggleShowsPanel(&state) + state.showsPanel.toggle() + return .none + + case .setOrientationPortrait(let isPortrait): + var effects = [Effect]() + if isPortrait { + effects.append(.run(operation: { _ in appDelegateClient.setPortraitOrientationMask() })) + effects.append(.run(operation: { _ in await appDelegateClient.setPortraitOrientation() })) + } else { + effects.append(.run(operation: { _ in appDelegateClient.setAllOrientationMask() })) + } + return .merge(effects) case .onPerformDismiss: - return handlePerformDismiss() + return .run(operation: { _ in hapticsClient.generateFeedback(.light) }) case .onAppear(let gid, let enablesLandscape): - return handleOnAppear(&state, gid: gid, enablesLandscape: enablesLandscape) - - case .teardown: - return handleTeardown(&state) - - // MARK: - Orientation Actions - case .setOrientationPortrait(let isPortrait): - return handleSetOrientationPortrait(isPortrait: isPortrait) + var effects: [Effect] = [ + .send(.fetchDatabaseInfos(gid)) + ] + if enablesLandscape { + effects.append(.send(.setOrientationPortrait(false))) + } + return .merge(effects) - // MARK: - Web Image Actions case .onWebImageRetry(let index): - return handleWebImageRetry(&state, index: index) + state.imageURLLoadingStates[index] = .idle + return .none case .onWebImageSucceeded(let index): - return handleWebImageSucceeded(&state, index: index) + state.imageURLLoadingStates[index] = .idle + state.webImageLoadSuccessIndices.insert(index) + return .none case .onWebImageFailed(let index): - return handleWebImageFailed(&state, index: index) + state.imageURLLoadingStates[index] = .failed(.webImageFailed) + return .none case .reloadAllWebImages: - return handleReloadAllWebImages(&state) + state.previewURLs = .init() + state.thumbnailURLs = .init() + state.imageURLs = .init() + state.originalImageURLs = .init() + state.mpvKey = nil + state.mpvImageKeys = .init() + state.mpvSkipServerIdentifiers = .init() + state.forceRefreshID = .init() + return .run { [state] _ in + await databaseClient.removeImageURLs(gid: state.gallery.id) + } case .retryAllFailedWebImages: - return handleRetryAllFailedWebImages(&state) + state.imageURLLoadingStates.forEach { (index, loadingState) in + if case .failed = loadingState { + state.imageURLLoadingStates[index] = .idle + } + } + state.previewLoadingStates.forEach { (index, loadingState) in + if case .failed = loadingState { + state.previewLoadingStates[index] = .idle + } + } + return .none - // MARK: - Image Actions case .copyImage(let imageURL): - return handleCopyImage(imageURL: imageURL) + return .send(.fetchImage(.copy(imageURL.isGIF), imageURL)) case .saveImage(let imageURL): - return handleSaveImage(imageURL: imageURL) + return .send(.fetchImage(.save(imageURL.isGIF), imageURL)) case .saveImageDone(let isSucceeded): - return handleSaveImageDone(&state, isSucceeded: isSucceeded) + state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error + return .send(.setNavigation(.hud)) case .shareImage(let imageURL): - return handleShareImage(imageURL: imageURL) + return .send(.fetchImage(.share(imageURL.isGIF), imageURL)) case .fetchImage(let action, let imageURL): - return handleFetchImage(action: action, imageURL: imageURL) + return .run { send in + let result = await imageClient.fetchImage(url: imageURL) + await send(.fetchImageDone(action, result)) + } + .cancellable(id: CancelID.fetchImage) case .fetchImageDone(let action, let result): - return handleFetchImageDone(&state, action: action, result: result) + if case .success(let image) = result { + switch action { + case .copy(let isAnimated): + state.hudConfig = .copiedToClipboardSucceeded + return .merge( + .send(.setNavigation(.hud)), + .run(operation: { _ in clipboardClient.saveImage(image, isAnimated) }) + ) + case .save(let isAnimated): + return .run { send in + let success = await imageClient.saveImageToPhotoLibrary(image, isAnimated) + await send(.saveImageDone(success)) + } + case .share(let isAnimated): + if isAnimated, let data = image.kf.data(format: .GIF) { + return .send(.setNavigation(.share(.init(value: .data(data))))) + } else { + return .send(.setNavigation(.share(.init(value: .image(image))))) + } + } + } else { + state.hudConfig = .error + return .send(.setNavigation(.hud)) + } - // MARK: - Synchronization Actions case .syncReadingProgress(let progress): - return handleSyncReadingProgress(state: state, progress: progress) + return .run { [state] _ in + await databaseClient.updateReadingProgress(gid: state.gallery.id, progress: progress) + } case .syncPreviewURLs(let previewURLs): - return handleSyncPreviewURLs(state: state, previewURLs: previewURLs) + return .run { [state] _ in + await databaseClient.updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs) + } case .syncThumbnailURLs(let thumbnailURLs): - return handleSyncThumbnailURLs(state: state, thumbnailURLs: thumbnailURLs) + return .run { [state] _ in + await databaseClient.updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs) + } case .syncImageURLs(let imageURLs, let originalImageURLs): - return handleSyncImageURLs( - state: state, - imageURLs: imageURLs, - originalImageURLs: originalImageURLs - ) + return .run { [state] _ in + await databaseClient.updateImageURLs( + gid: state.gallery.id, + imageURLs: imageURLs, + originalImageURLs: originalImageURLs + ) + } + + case .teardown: + var effects: [Effect] = [ + .merge(CancelID.allCases.map(Effect.cancel(id:))) + ] + if !deviceClient.isPad() { + effects.append(.send(.setOrientationPortrait(true))) + } + return .merge(effects) - // MARK: - Database Actions case .fetchDatabaseInfos(let gid): - return handleFetchDatabaseInfos(&state, gid: gid) + guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } + state.gallery = gallery + state.galleryDetail = databaseClient.fetchGalleryDetail(gid: state.gallery.id) + return .run { [state] send in + guard let dbState = await databaseClient.fetchGalleryState(gid: state.gallery.id) else { return } + await send(.fetchDatabaseInfosDone(dbState)) + } + .cancellable(id: CancelID.fetchDatabaseInfos) case .fetchDatabaseInfosDone(let galleryState): - return handleFetchDatabaseInfosDone(&state, galleryState: galleryState) + if let previewConfig = galleryState.previewConfig { + state.previewConfig = previewConfig + } + state.previewURLs = galleryState.previewURLs + state.imageURLs = galleryState.imageURLs + state.thumbnailURLs = galleryState.thumbnailURLs + state.originalImageURLs = galleryState.originalImageURLs + state.readingProgress = galleryState.readingProgress + state.databaseLoadingState = .idle + return .none - // MARK: - Preview Actions case .fetchPreviewURLs(let index): - return handleFetchPreviewURLs(&state, index: index) + guard state.previewLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + state.previewLoadingStates[index] = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + return .run { send in + let response = await GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() + await send(.fetchPreviewURLsDone(index, response)) + } + .cancellable(id: CancelID.fetchPreviewURLs) case .fetchPreviewURLsDone(let index, let result): - return handleFetchPreviewURLsDone(&state, index: index, result: result) + switch result { + case .success(let previewURLs): + guard !previewURLs.isEmpty else { + state.previewLoadingStates[index] = .failed(.notFound) + return .none + } + state.previewLoadingStates[index] = .idle + state.updatePreviewURLs(previewURLs) + return .send(.syncPreviewURLs(previewURLs)) + case .failure(let error): + state.previewLoadingStates[index] = .failed(error) + } + return .none - // MARK: - Image URL Actions case .fetchImageURLs(let index): - return handleFetchImageURLs(&state, index: index) + if state.mpvKey != nil { + return .send(.fetchMPVImageURL(index, false)) + } else { + return .send(.fetchThumbnailURLs(index)) + } case .refetchImageURLs(let index): - return handleRefetchImageURLs(&state, index: index) + if state.mpvKey != nil { + return .send(.fetchMPVImageURL(index, true)) + } else { + return .send(.refetchNormalImageURLs(index)) + } case .prefetchImages(let index, let prefetchLimit): - return handlePrefetchImages(&state, index: index, prefetchLimit: prefetchLimit) + func getPrefetchImageURLs(range: ClosedRange) -> [URL] { + (range.lowerBound...range.upperBound).compactMap { index in + if let url = state.imageURLs[index] { + return url + } + return nil + } + } + func getFetchImageURLIndices(range: ClosedRange) -> [Int] { + (range.lowerBound...range.upperBound).compactMap { index in + if state.imageURLs[index] == nil, state.imageURLLoadingStates[index] != .loading { + return index + } + return nil + } + } + var prefetchImageURLs = [URL]() + var fetchImageURLIndices = [Int]() + var effects = [Effect]() + let previousUpperBound = max(index - 2, 1) + let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) + if previousUpperBound - previousLowerBound > 0 { + prefetchImageURLs += getPrefetchImageURLs(range: previousLowerBound...previousUpperBound) + fetchImageURLIndices += getFetchImageURLIndices(range: previousLowerBound...previousUpperBound) + } + let nextLowerBound = min(index + 2, state.gallery.pageCount) + let nextUpperBound = min(nextLowerBound + prefetchLimit / 2, state.gallery.pageCount) + if nextUpperBound - nextLowerBound > 0 { + prefetchImageURLs += getPrefetchImageURLs(range: nextLowerBound...nextUpperBound) + fetchImageURLIndices += getFetchImageURLIndices(range: nextLowerBound...nextUpperBound) + } + fetchImageURLIndices.forEach { + effects.append(.send(.fetchImageURLs($0))) + } + effects.append( + .run { [prefetchImageURLs] _ in + imageClient.prefetchImages(prefetchImageURLs) + } + ) + return .merge(effects) - // MARK: - Thumbnail Actions case .fetchThumbnailURLs(let index): - return handleFetchThumbnailURLs(&state, index: index) + guard state.imageURLLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + state.previewConfig.batchRange(index: index).forEach { + state.imageURLLoadingStates[$0] = .loading + } + let pageNum = state.previewConfig.pageNumber(index: index) + return .run { send in + let response = await ThumbnailURLsRequest(galleryURL: galleryURL, pageNum: pageNum).response() + await send(.fetchThumbnailURLsDone(index, response)) + } + .cancellable(id: CancelID.fetchThumbnailURLs) case .fetchThumbnailURLsDone(let index, let result): - return handleFetchThumbnailURLsDone(&state, index: index, result: result) + let batchRange = state.previewConfig.batchRange(index: index) + switch result { + case .success(let thumbnailURLs): + guard !thumbnailURLs.isEmpty else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) + } + return .none + } + if let url = thumbnailURLs[index], urlClient.checkIfMPVURL(url) { + return .send(.fetchMPVKeys(index, url)) + } else { + state.updateThumbnailURLs(thumbnailURLs) + return .merge( + .send(.syncThumbnailURLs(thumbnailURLs)), + .send(.fetchNormalImageURLs(index, thumbnailURLs)) + ) + } + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + } + return .none - // MARK: - Normal Image Actions case .fetchNormalImageURLs(let index, let thumbnailURLs): - return handleFetchNormalImageURLs(index: index, thumbnailURLs: thumbnailURLs) + return .run { send in + let response = await GalleryNormalImageURLsRequest(thumbnailURLs: thumbnailURLs).response() + await send(.fetchNormalImageURLsDone(index, response)) + } + .cancellable(id: CancelID.fetchNormalImageURLs) case .fetchNormalImageURLsDone(let index, let result): - return handleFetchNormalImageURLsDone(&state, index: index, result: result) + let batchRange = state.previewConfig.batchRange(index: index) + switch result { + case .success(let (imageURLs, originalImageURLs)): + guard !imageURLs.isEmpty else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) + } + return .none + } + batchRange.forEach { + state.imageURLLoadingStates[$0] = .idle + } + state.updateImageURLs(imageURLs, originalImageURLs) + return .send(.syncImageURLs(imageURLs, originalImageURLs)) + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + } + return .none case .refetchNormalImageURLs(let index): - return handleRefetchNormalImageURLs(&state, index: index) + guard state.imageURLLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL, + let imageURL = state.imageURLs[index] + else { return .none } + state.imageURLLoadingStates[index] = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + return .run { [thumbnailURL = state.thumbnailURLs[index]] send in + let response = await GalleryNormalImageURLRefetchRequest( + index: index, + pageNum: pageNum, + galleryURL: galleryURL, + thumbnailURL: thumbnailURL, + storedImageURL: imageURL + ) + .response() + await send(.refetchNormalImageURLsDone(index, response)) + } + .cancellable(id: CancelID.refetchNormalImageURLs) case .refetchNormalImageURLsDone(let index, let result): - return handleRefetchNormalImageURLsDone(&state, index: index, result: result) + switch result { + case .success(let (imageURLs, response)): + var effects = [Effect]() + if let response = response { + effects.append(.run(operation: { _ in cookieClient.setSkipServer(response: response) })) + } + guard !imageURLs.isEmpty else { + state.imageURLLoadingStates[index] = .failed(.notFound) + return effects.isEmpty ? .none : .merge(effects) + } + state.imageURLLoadingStates[index] = .idle + state.updateImageURLs(imageURLs, [:]) + effects.append(.send(.syncImageURLs(imageURLs, [:]))) + return .merge(effects) + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) + } + return .none - // MARK: - MPV Actions case .fetchMPVKeys(let index, let mpvURL): - return handleFetchMPVKeys(index: index, mpvURL: mpvURL) + return .run { send in + let response = await MPVKeysRequest(mpvURL: mpvURL).response() + await send(.fetchMPVKeysDone(index, response)) + } + .cancellable(id: CancelID.fetchMPVKeys) case .fetchMPVKeysDone(let index, let result): - return handleFetchMPVKeysDone(&state, index: index, result: result) - - case .fetchMPVImageURL(let index, let isRefresh): - return handleFetchMPVImageURL(&state, index: index, isRefresh: isRefresh) - - case .fetchMPVImageURLDone(let index, let result): - return handleFetchMPVImageURLDone(&state, index: index, result: result) - } - } - .haptics(unwrapping: \.route, case: \.readingSetting, hapticsClient: hapticsClient) - .haptics(unwrapping: \.route, case: \.share, hapticsClient: hapticsClient) - } - - // MARK: - Handler Methods - - /// Basic Action Handlers - func handleSetNavigation(_ state: inout State, route: Route?) -> Effect { - state.route = route - return .none - } - - func handleToggleShowsPanel(_ state: inout State) -> Effect { - state.showsPanel.toggle() - return .none - } - - func handlePerformDismiss() -> Effect { - return .run(operation: { _ in - hapticsClient.generateFeedback(.light) - }) - } - - func handleOnAppear(_ state: inout State, gid: String, enablesLandscape: Bool) -> Effect { - var effects: [Effect] = [ - .send(.fetchDatabaseInfos(gid)) - ] - if enablesLandscape { - effects.append(.send(.setOrientationPortrait(false))) - } - return .merge(effects) - } - - func handleTeardown(_ state: inout State) -> Effect { - var effects: [Effect] = [ - .merge(CancelID.allCases.map(Effect.cancel(id:))) - ] - if !deviceClient.isPad() { - effects.append(.send(.setOrientationPortrait(true))) - } - return .merge(effects) - } - - /// Orientation Handlers - func handleSetOrientationPortrait(isPortrait: Bool) -> Effect { - var effects = [Effect]() - if isPortrait { - effects.append(.run(operation: { _ in - appDelegateClient.setPortraitOrientationMask() - })) - effects.append(.run(operation: { _ in - await appDelegateClient.setPortraitOrientation() - })) - } else { - effects.append(.run(operation: { _ in - appDelegateClient.setAllOrientationMask() - })) - } - return .merge(effects) - } - - /// Web Image Handlers - func handleWebImageRetry(_ state: inout State, index: Int) -> Effect { - state.imageURLLoadingStates[index] = .idle - return .none - } - - func handleWebImageSucceeded(_ state: inout State, index: Int) -> Effect { - state.imageURLLoadingStates[index] = .idle - state.webImageLoadSuccessIndices.insert(index) - return .none - } - - func handleWebImageFailed(_ state: inout State, index: Int) -> Effect { - state.imageURLLoadingStates[index] = .failed(.webImageFailed) - return .none - } - - func handleReloadAllWebImages(_ state: inout State) -> Effect { - state.previewURLs = .init() - state.thumbnailURLs = .init() - state.imageURLs = .init() - state.originalImageURLs = .init() - state.mpvKey = nil - state.mpvImageKeys = .init() - state.mpvSkipServerIdentifiers = .init() - state.forceRefreshID = .init() - - return .run { [galleryId = state.gallery.id] _ in - await databaseClient.removeImageURLs(gid: galleryId) - } - } - - func handleRetryAllFailedWebImages(_ state: inout State) -> Effect { - state.imageURLLoadingStates.forEach { (index, loadingState) in - if case .failed = loadingState { - state.imageURLLoadingStates[index] = .idle - } - } - state.previewLoadingStates.forEach { (index, loadingState) in - if case .failed = loadingState { - state.previewLoadingStates[index] = .idle - } - } - return .none - } - - /// Image Action Handlers - func handleCopyImage(imageURL: URL) -> Effect { - return .send(.fetchImage(.copy(imageURL.isGIF), imageURL)) - } - - func handleSaveImage(imageURL: URL) -> Effect { - return .send(.fetchImage(.save(imageURL.isGIF), imageURL)) - } - - func handleSaveImageDone(_ state: inout State, isSucceeded: Bool) -> Effect { - state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error - return .send(.setNavigation(.hud)) - } - - func handleShareImage(imageURL: URL) -> Effect { - return .send(.fetchImage(.share(imageURL.isGIF), imageURL)) - } - - func handleFetchImage(action: ImageAction, imageURL: URL) -> Effect { - return .run { send in - let result = await imageClient.fetchImage(url: imageURL) - await send(.fetchImageDone(action, result)) - } - .cancellable(id: CancelID.fetchImage) - } - - func handleFetchImageDone( - _ state: inout State, - action: ImageAction, - result: Result - ) -> Effect { - switch result { - case .success(let image): - return handleSuccessfulImageFetch(state: &state, action: action, image: image) - case .failure: - state.hudConfig = .error - return .send(.setNavigation(.hud)) - } - } - - private func handleSuccessfulImageFetch( - state: inout State, - action: ImageAction, - image: UIImage - ) -> Effect { - switch action { - case .copy(let isAnimated): - state.hudConfig = .copiedToClipboardSucceeded - return .merge( - .send(.setNavigation(.hud)), - .run(operation: { _ in - clipboardClient.saveImage(image, isAnimated) - }) - ) - case .save(let isAnimated): - return .run { send in - let success = await imageClient.saveImageToPhotoLibrary(image, isAnimated) - await send(.saveImageDone(success)) - } - case .share(let isAnimated): - if isAnimated, let data = image.kf.data(format: .GIF) { - return .send(.setNavigation(.share(.init(value: .data(data))))) - } else { - return .send(.setNavigation(.share(.init(value: .image(image))))) - } - } - } - - /// Synchronization Handlers - func handleSyncReadingProgress(state: State, progress: Int) -> Effect { - return .run { _ in - await databaseClient.updateReadingProgress( - gid: state.gallery.id, - progress: progress - ) - } - } - - func handleSyncPreviewURLs(state: State, previewURLs: [Int: URL]) -> Effect { - return .run { _ in - await databaseClient.updatePreviewURLs( - gid: state.gallery.id, - previewURLs: previewURLs - ) - } - } - - func handleSyncThumbnailURLs(state: State, thumbnailURLs: [Int: URL]) -> Effect { - return .run { _ in - await databaseClient.updateThumbnailURLs( - gid: state.gallery.id, - thumbnailURLs: thumbnailURLs - ) - } - } - - func handleSyncImageURLs( - state: State, - imageURLs: [Int: URL], - originalImageURLs: [Int: URL] - ) -> Effect { - return .run { _ in - await databaseClient.updateImageURLs( - gid: state.gallery.id, - imageURLs: imageURLs, - originalImageURLs: originalImageURLs - ) - } - } - - /// Database Handlers - func handleFetchDatabaseInfos(_ state: inout State, gid: String) -> Effect { - guard let gallery = databaseClient.fetchGallery(gid: gid) else { - return .none - } - - state.gallery = gallery - state.galleryDetail = databaseClient.fetchGalleryDetail(gid: state.gallery.id) - - return .run { [galleryId = state.gallery.id] send in - guard let dbState = await databaseClient.fetchGalleryState(gid: galleryId) else { - return - } - await send(.fetchDatabaseInfosDone(dbState)) - } - .cancellable(id: CancelID.fetchDatabaseInfos) - } - - func handleFetchDatabaseInfosDone( - _ state: inout State, - galleryState: GalleryState - ) -> Effect { - if let previewConfig = galleryState.previewConfig { - state.previewConfig = previewConfig - } - state.previewURLs = galleryState.previewURLs - state.imageURLs = galleryState.imageURLs - state.thumbnailURLs = galleryState.thumbnailURLs - state.originalImageURLs = galleryState.originalImageURLs - state.readingProgress = galleryState.readingProgress - state.databaseLoadingState = .idle - return .none - } - - /// Preview Handlers - func handleFetchPreviewURLs(_ state: inout State, index: Int) -> Effect { - guard state.previewLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL - else { - return .none - } - - state.previewLoadingStates[index] = .loading - let pageNum = state.previewConfig.pageNumber(index: index) - - return .run { send in - let response = await GalleryPreviewURLsRequest( - galleryURL: galleryURL, - pageNum: pageNum - ).response() - await send(.fetchPreviewURLsDone(index, response)) - } - .cancellable(id: CancelID.fetchPreviewURLs) - } - - func handleFetchPreviewURLsDone( - _ state: inout State, - index: Int, - result: Result<[Int: URL], AppError> - ) -> Effect { - switch result { - case .success(let previewURLs): - guard !previewURLs.isEmpty else { - state.previewLoadingStates[index] = .failed(.notFound) - return .none - } - state.previewLoadingStates[index] = .idle - state.updatePreviewURLs(previewURLs) - return .send(.syncPreviewURLs(previewURLs)) - case .failure(let error): - state.previewLoadingStates[index] = .failed(error) - return .none - } - } - - /// Image URL Handlers - func handleFetchImageURLs(_ state: inout State, index: Int) -> Effect { - if state.mpvKey != nil { - return .send(.fetchMPVImageURL(index, false)) - } else { - return .send(.fetchThumbnailURLs(index)) - } - } - - func handleRefetchImageURLs(_ state: inout State, index: Int) -> Effect { - if state.mpvKey != nil { - return .send(.fetchMPVImageURL(index, true)) - } else { - return .send(.refetchNormalImageURLs(index)) - } - } - - func handlePrefetchImages( - _ state: inout State, - index: Int, - prefetchLimit: Int - ) -> Effect { - let prefetchHelper = PrefetchHelper(state: state, imageClient: imageClient) - return prefetchHelper.createPrefetchEffects( - currentIndex: index, - prefetchLimit: prefetchLimit - ) - } - - /// Thumbnail Handlers - func handleFetchThumbnailURLs(_ state: inout State, index: Int) -> Effect { - guard state.imageURLLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL - else { - return .none - } - - state.previewConfig.batchRange(index: index).forEach { - state.imageURLLoadingStates[$0] = .loading - } - - let pageNum = state.previewConfig.pageNumber(index: index) - - return .run { send in - let response = await ThumbnailURLsRequest( - galleryURL: galleryURL, - pageNum: pageNum - ).response() - await send(.fetchThumbnailURLsDone(index, response)) - } - .cancellable(id: CancelID.fetchThumbnailURLs) - } - - func handleFetchThumbnailURLsDone( - _ state: inout State, - index: Int, - result: Result<[Int: URL], AppError> - ) -> Effect { - let batchRange = state.previewConfig.batchRange(index: index) - - switch result { - case .success(let thumbnailURLs): - guard !thumbnailURLs.isEmpty else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) + let batchRange = state.previewConfig.batchRange(index: index) + switch result { + case .success(let (mpvKey, mpvImageKeys)): + let pageCount = state.gallery.pageCount + guard mpvImageKeys.count == pageCount else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) + } + return .none + } + batchRange.forEach { + state.imageURLLoadingStates[$0] = .idle + } + state.mpvKey = mpvKey + state.mpvImageKeys = mpvImageKeys + return .merge( + Array(1...min(3, max(1, pageCount))).map { + .send(.fetchMPVImageURL($0, false)) + } + ) + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } } return .none - } - - if let url = thumbnailURLs[index], urlClient.checkIfMPVURL(url) { - return .send(.fetchMPVKeys(index, url)) - } else { - state.updateThumbnailURLs(thumbnailURLs) - return .merge( - .send(.syncThumbnailURLs(thumbnailURLs)), - .send(.fetchNormalImageURLs(index, thumbnailURLs)) - ) - } - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - return .none - } - } - - /// Normal Image Handlers - func handleFetchNormalImageURLs( - index: Int, - thumbnailURLs: [Int: URL] - ) -> Effect { - return .run { send in - let response = await GalleryNormalImageURLsRequest( - thumbnailURLs: thumbnailURLs - ).response() - await send(.fetchNormalImageURLsDone(index, response)) - } - .cancellable(id: CancelID.fetchNormalImageURLs) - } - func handleFetchNormalImageURLsDone( - _ state: inout State, - index: Int, - result: Result<([Int: URL], [Int: URL]), AppError> - ) -> Effect { - let batchRange = state.previewConfig.batchRange(index: index) - - switch result { - case .success(let (imageURLs, originalImageURLs)): - guard !imageURLs.isEmpty else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) + case .fetchMPVImageURL(let index, let isRefresh): + guard let gidInteger = Int(state.gallery.id), let mpvKey = state.mpvKey, + let mpvImageKey = state.mpvImageKeys[index], + state.imageURLLoadingStates[index] != .loading + else { return .none } + state.imageURLLoadingStates[index] = .loading + let skipServerIdentifier = isRefresh ? state.mpvSkipServerIdentifiers[index] : nil + return .run { send in + let response = await GalleryMPVImageURLRequest( + gid: gidInteger, + index: index, + mpvKey: mpvKey, + mpvImageKey: mpvImageKey, + skipServerIdentifier: skipServerIdentifier + ) + .response() + await send(.fetchMPVImageURLDone(index, response)) } - return .none - } - - batchRange.forEach { - state.imageURLLoadingStates[$0] = .idle - } - state.updateImageURLs(imageURLs, originalImageURLs) - return .send(.syncImageURLs(imageURLs, originalImageURLs)) - - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - return .none - } - } - - func handleRefetchNormalImageURLs(_ state: inout State, index: Int) -> Effect { - guard state.imageURLLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL, - let imageURL = state.imageURLs[index] - else { - return .none - } - - state.imageURLLoadingStates[index] = .loading - let pageNum = state.previewConfig.pageNumber(index: index) - - return .run { [thumbnailURL = state.thumbnailURLs[index]] send in - let response = await GalleryNormalImageURLRefetchRequest( - index: index, - pageNum: pageNum, - galleryURL: galleryURL, - thumbnailURL: thumbnailURL, - storedImageURL: imageURL - ).response() - await send(.refetchNormalImageURLsDone(index, response)) - } - .cancellable(id: CancelID.refetchNormalImageURLs) - } - - func handleRefetchNormalImageURLsDone( - _ state: inout State, - index: Int, - result: Result<([Int: URL], HTTPURLResponse?), AppError> - ) -> Effect { - switch result { - case .success(let (imageURLs, response)): - var effects = [Effect]() - - if let response = response { - effects.append(.run(operation: { _ in - cookieClient.setSkipServer(response: response) - })) - } - - guard !imageURLs.isEmpty else { - state.imageURLLoadingStates[index] = .failed(.notFound) - return effects.isEmpty ? .none : .merge(effects) - } + .cancellable(id: CancelID.fetchMPVImageURL) - state.imageURLLoadingStates[index] = .idle - state.updateImageURLs(imageURLs, [:]) - effects.append(.send(.syncImageURLs(imageURLs, [:]))) - return .merge(effects) - - case .failure(let error): - state.imageURLLoadingStates[index] = .failed(error) - return .none - } - } - - /// MPV Handlers - func handleFetchMPVKeys(index: Int, mpvURL: URL) -> Effect { - return .run { send in - let response = await MPVKeysRequest(mpvURL: mpvURL).response() - await send(.fetchMPVKeysDone(index, response)) - } - .cancellable(id: CancelID.fetchMPVKeys) - } - - func handleFetchMPVKeysDone( - _ state: inout State, - index: Int, - result: Result<(String, [Int: String]), AppError> - ) -> Effect { - let batchRange = state.previewConfig.batchRange(index: index) - - switch result { - case .success(let (mpvKey, mpvImageKeys)): - let pageCount = state.gallery.pageCount - guard mpvImageKeys.count == pageCount else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) + case .fetchMPVImageURLDone(let index, let result): + switch result { + case .success(let (imageURL, originalImageURL, skipServerIdentifier)): + let imageURLs: [Int: URL] = [index: imageURL] + var originalImageURLs = [Int: URL]() + if let originalImageURL = originalImageURL { + originalImageURLs[index] = originalImageURL + } + state.imageURLLoadingStates[index] = .idle + state.mpvSkipServerIdentifiers[index] = skipServerIdentifier + state.updateImageURLs(imageURLs, originalImageURLs) + return .send(.syncImageURLs(imageURLs, originalImageURLs)) + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) } return .none } - - batchRange.forEach { - state.imageURLLoadingStates[$0] = .idle - } - state.mpvKey = mpvKey - state.mpvImageKeys = mpvImageKeys - - return .merge( - Array(1...min(3, max(1, pageCount))).map { - .send(.fetchMPVImageURL($0, false)) - } - ) - - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - return .none - } - } - - func handleFetchMPVImageURL( - _ state: inout State, - index: Int, - isRefresh: Bool - ) -> Effect { - guard let gidInteger = Int(state.gallery.id), - let mpvKey = state.mpvKey, - let mpvImageKey = state.mpvImageKeys[index], - state.imageURLLoadingStates[index] != .loading - else { - return .none - } - - state.imageURLLoadingStates[index] = .loading - let skipServerIdentifier = isRefresh ? state.mpvSkipServerIdentifiers[index] : nil - - return .run { send in - let response = await GalleryMPVImageURLRequest( - gid: gidInteger, - index: index, - mpvKey: mpvKey, - mpvImageKey: mpvImageKey, - skipServerIdentifier: skipServerIdentifier - ).response() - await send(.fetchMPVImageURLDone(index, response)) - } - .cancellable(id: CancelID.fetchMPVImageURL) - } - - func handleFetchMPVImageURLDone( - _ state: inout State, - index: Int, - result: Result<(URL, URL?, String), AppError> - ) -> Effect { - switch result { - case .success(let (imageURL, originalImageURL, skipServerIdentifier)): - let imageURLs: [Int: URL] = [index: imageURL] - var originalImageURLs = [Int: URL]() - if let originalImageURL = originalImageURL { - originalImageURLs[index] = originalImageURL - } - - state.imageURLLoadingStates[index] = .idle - state.mpvSkipServerIdentifiers[index] = skipServerIdentifier - state.updateImageURLs(imageURLs, originalImageURLs) - return .send(.syncImageURLs(imageURLs, originalImageURLs)) - - case .failure(let error): - state.imageURLLoadingStates[index] = .failed(error) - return .none } - } -} - -// MARK: - State Extensions -extension ReadingReducer.State { - /// Updates preview URLs - mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { - guard !previewURLs.isEmpty else { return } - self.previewURLs = self.previewURLs.merging(previewURLs) { _, new in new } - } - - /// Updates thumbnail URLs - mutating func updateThumbnailURLs(_ thumbnailURLs: [Int: URL]) { - guard !thumbnailURLs.isEmpty else { return } - self.thumbnailURLs = self.thumbnailURLs.merging(thumbnailURLs) { _, new in new } - } - - /// Updates image URLs and original image URLs - mutating func updateImageURLs(_ imageURLs: [Int: URL], _ originalImageURLs: [Int: URL]) { - if !imageURLs.isEmpty { - self.imageURLs = self.imageURLs.merging(imageURLs) { _, new in new } - } - if !originalImageURLs.isEmpty { - self.originalImageURLs = self.originalImageURLs.merging(originalImageURLs) { _, new in new } - } - } - - /// Gets container data source for the current configuration - func containerDataSource(setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> [Int] { - let defaultData = Array(1...gallery.pageCount) - - guard isLandscape && - setting.enablesDualPageMode && - setting.readingDirection != .vertical - else { - return defaultData - } - - let data = setting.exceptCover - ? [1] + Array(stride(from: 2, through: gallery.pageCount, by: 2)) - : Array(stride(from: 1, through: gallery.pageCount, by: 2)) - - return data - } - - /// Gets image container configurations for dual page mode - func imageContainerConfigs( - index: Int, - setting: Setting, - isLandscape: Bool = DeviceUtil.isLandscape - ) -> ImageStackConfig { - let direction = setting.readingDirection - let isReversed = direction == .rightToLeft - let isFirstSingle = setting.exceptCover - let isFirstPageAndSingle = index == 1 && isFirstSingle - let isDualPage = isLandscape && setting.enablesDualPageMode && direction != .vertical - - let firstIndex = isDualPage && isReversed && !isFirstPageAndSingle ? index + 1 : index - let secondIndex = firstIndex + (isReversed ? -1 : 1) - - let isValidFirstRange = firstIndex >= 1 && firstIndex <= gallery.pageCount - let isValidSecondRange = isFirstSingle - ? secondIndex >= 2 && secondIndex <= gallery.pageCount - : secondIndex >= 1 && secondIndex <= gallery.pageCount - - let dualPageConfig = DualPageConfiguration( - firstIndex: firstIndex, - secondIndex: secondIndex, - isFirstAvailable: isValidFirstRange, - isSecondAvailable: !isFirstPageAndSingle && isValidSecondRange && isDualPage, - isDualPage: isDualPage - ) - - return ImageStackConfig(from: dualPageConfig) - } -} - -// MARK: - Helper Classes - -/// Helper class for managing prefetch operations -private struct PrefetchHelper { - let state: ReadingReducer.State - let imageClient: ImageClient - - func createPrefetchEffects(currentIndex: Int, prefetchLimit: Int) -> Effect { - let (prefetchURLs, fetchIndices) = calculatePrefetchData( - currentIndex: currentIndex, - prefetchLimit: prefetchLimit + .haptics( + unwrapping: \.route, + case: \.readingSetting, + hapticsClient: hapticsClient ) - - var effects = fetchIndices.map { index in - Effect.send(.fetchImageURLs(index)) - } - - effects.append( - .run { _ in - imageClient.prefetchImages(prefetchURLs) - } + .haptics( + unwrapping: \.route, + case: \.share, + hapticsClient: hapticsClient ) - - return .merge(effects) - } - - private func calculatePrefetchData( - currentIndex: Int, - prefetchLimit: Int - ) -> (urls: [URL], indices: [Int]) { - var prefetchURLs = [URL]() - var fetchIndices = [Int]() - - // Previous pages - let previousUpperBound = max(currentIndex - 2, 1) - let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) - if previousUpperBound - previousLowerBound > 0 { - let previousRange = previousLowerBound...previousUpperBound - prefetchURLs += getURLsForRange(previousRange) - fetchIndices += getIndicesNeedingFetch(previousRange) - } - - // Next pages - let nextLowerBound = min(currentIndex + 2, state.gallery.pageCount) - let nextUpperBound = min(nextLowerBound + prefetchLimit / 2, state.gallery.pageCount) - if nextUpperBound - nextLowerBound > 0 { - let nextRange = nextLowerBound...nextUpperBound - prefetchURLs += getURLsForRange(nextRange) - fetchIndices += getIndicesNeedingFetch(nextRange) - } - - return (prefetchURLs, fetchIndices) - } - - private func getURLsForRange(_ range: ClosedRange) -> [URL] { - return range.compactMap { index in - state.imageURLs[index] - } - } - - private func getIndicesNeedingFetch(_ range: ClosedRange) -> [Int] { - return range.compactMap { index in - if state.imageURLs[index] == nil && - state.imageURLLoadingStates[index] != .loading { - return index - } - return nil - } } } diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index e55266a4..504213a3 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -8,466 +8,624 @@ import Kingfisher import SwiftUIPager import ComposableArchitecture -// MARK: - Main Reading View struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme - @Bindable var store: StoreOf - // MARK: - Configuration + @Bindable var store: StoreOf private let gid: String @Binding private var setting: Setting private let blurRadius: Double - // MARK: - View Models - @StateObject private var viewModel: ReadingViewModel - @StateObject private var gestureCoordinator: GestureCoordinator - @StateObject private var pageCoordinator: PageCoordinator + @StateObject private var liveTextHandler = LiveTextHandler() + @StateObject private var autoPlayHandler = AutoPlayHandler() + @StateObject private var gestureHandler = GestureHandler() + @StateObject private var pageHandler = PageHandler() @StateObject private var page: Page = .first() - // MARK: - Initialization init( store: StoreOf, - gid: String, - setting: Binding, - blurRadius: Double + gid: String, setting: Binding, blurRadius: Double ) { self.store = store self.gid = gid _setting = setting self.blurRadius = blurRadius + } - // Initialize view models with dependencies - _viewModel = StateObject(wrappedValue: ReadingViewModel()) - _gestureCoordinator = StateObject(wrappedValue: GestureCoordinator()) - _pageCoordinator = StateObject(wrappedValue: PageCoordinator()) + private var backgroundColor: Color { + colorScheme == .light ? Color(.systemGray4) : Color(.systemGray6) } - // MARK: - Body var body: some View { + changeTriggers(content: { content }) + .sheet(item: $store.route.sending(\.setNavigation).readingSetting) { _ in + NavigationView { + ReadingSettingView( + readingDirection: $setting.readingDirection, + prefetchLimit: $setting.prefetchLimit, + enablesLandscape: $setting.enablesLandscape, + contentDividerHeight: $setting.contentDividerHeight, + maximumScaleFactor: $setting.maximumScaleFactor, + doubleTapScaleFactor: $setting.doubleTapScaleFactor + ) + .toolbar { + if !DeviceUtil.isPad && DeviceUtil.isLandscape { + CustomToolbarItem(placement: .cancellationAction) { + Button { + store.send(.setNavigation(nil)) + } label: { + Image(systemSymbol: .chevronDown) + } + } + } + } + } + .accentColor(setting.accentColor) + .tint(setting.accentColor) + .autoBlur(radius: blurRadius) + .navigationViewStyle(.stack) + } + .sheet(item: $store.route.sending(\.setNavigation).share) { shareItemBox in + ActivityView(activityItems: [shareItemBox.wrappedValue.associatedValue]) + .accentColor(setting.accentColor) + .autoBlur(radius: blurRadius) + } + .progressHUD( + config: store.hudConfig, + unwrapping: $store.route, + case: \.hud + ) + + .animation(.linear(duration: 0.1), value: gestureHandler.offset) + .animation(.default, value: liveTextHandler.enablesLiveText) + .animation(.default, value: liveTextHandler.liveTextGroups) + .animation(.default, value: gestureHandler.scale) + .animation(.default, value: store.showsPanel) + .statusBar(hidden: !store.showsPanel) + .onDisappear { + liveTextHandler.cancelRequests() + setAutoPlayPolocy(.off) + } + .onAppear { store.send(.onAppear(gid, setting.enablesLandscape)) } + } + + var content: some View { ZStack { backgroundColor.ignoresSafeArea() - ReadingContentView( - store: store, - setting: $setting, - viewModel: viewModel, - gestureCoordinator: gestureCoordinator, - pageCoordinator: pageCoordinator, - page: page + ZStack { + if setting.readingDirection == .vertical { + AdvancedList( + page: page, + data: store.state.containerDataSource(setting: setting), + id: \.self, + spacing: setting.contentDividerHeight, + gesture: SimultaneousGesture(magnificationGesture, tapGesture), + content: imageStack + ) + .scrollDisabled(gestureHandler.scale != 1) + } else { + Pager( + page: page, + data: store.state.containerDataSource(setting: setting), + id: \.self, + content: imageStack + ) + .horizontal(setting.readingDirection == .rightToLeft ? .endToStart : .startToEnd) + .swipeInteractionArea(.allAvailable) + .allowsDragging(gestureHandler.scale == 1) + } + } + .scaleEffect(gestureHandler.scale, anchor: gestureHandler.scaleAnchor) + .offset(gestureHandler.offset) + .highPriorityGesture( + dragGesture.simultaneously(with: tapGesture), + isEnabled: gestureHandler.scale > 1 ) - - ReadingControlsOverlay( - store: store, - setting: $setting, - viewModel: viewModel, - pageCoordinator: pageCoordinator, - gestureCoordinator: gestureCoordinator, - page: page + .gesture(tapGesture, isEnabled: gestureHandler.scale == 1) + .gesture(magnificationGesture) + .ignoresSafeArea() + .id(store.databaseLoadingState) + .id(store.forceRefreshID) + + ControlPanel( + showsPanel: $store.showsPanel, + showsSliderPreview: $store.showsSliderPreview, + sliderValue: $pageHandler.sliderValue, setting: $setting, + enablesLiveText: $liveTextHandler.enablesLiveText, + autoPlayPolicy: .init(get: { autoPlayHandler.policy }, set: { setAutoPlayPolocy($0) }), + range: 1...Float(store.gallery.pageCount), + previewURLs: store.previewURLs, + dismissGesture: controlPanelDismissGesture, + dismissAction: { store.send(.onPerformDismiss) }, + navigateSettingAction: { store.send(.setNavigation(.readingSetting())) }, + reloadAllImagesAction: { store.send(.reloadAllWebImages) }, + retryAllFailedImagesAction: { store.send(.retryAllFailedWebImages) }, + fetchPreviewURLsAction: { store.send(.fetchPreviewURLs($0)) } ) } - .readingViewModifiers( - store: store, - setting: $setting, - blurRadius: blurRadius - ) - .onAppear { - store.send(.onAppear(gid, setting.enablesLandscape)) - setupViewModels() - } - .onDisappear { - cleanup() - } - .observeReadingChanges( - store: store, - setting: $setting, - viewModel: viewModel, - pageCoordinator: pageCoordinator, - page: page - ) } - // MARK: - Computed Properties - private var backgroundColor: Color { - colorScheme == .light ? Color(.systemGray4) : Color(.systemGray6) - } + @ViewBuilder + private func changeTriggers(@ViewBuilder content: () -> Content) -> some View { + content() + // Page + .onChange(of: page.index) { _, newValue in + Logger.info("page.index changed", context: ["pageIndex": newValue]) + let newValue = pageHandler.mapFromPager( + index: newValue, pageCount: store.gallery.pageCount, setting: setting + ) + pageHandler.sliderValue = .init(newValue) + if store.databaseLoadingState == .idle { + store.send(.syncReadingProgress(.init(newValue))) + } + } + .onChange(of: pageHandler.sliderValue) { _, newValue in + Logger.info("pageHandler.sliderValue changed", context: ["sliderValue": newValue]) + if !store.showsSliderPreview { + setPageIndex(sliderValue: newValue) + } + } + .onChange(of: store.showsSliderPreview) { _, newValue in + Logger.info("store.showsSliderPreview changed", context: ["isShown": newValue]) + if !newValue { setPageIndex(sliderValue: pageHandler.sliderValue) } + setAutoPlayPolocy(.off) + } + .onChange(of: store.readingProgress) { _, newValue in + Logger.info("store.readingProgress changed", context: ["readingProgress": newValue]) + pageHandler.sliderValue = .init(newValue) + } - // MARK: - Helper Methods - private func setupViewModels() { - viewModel.setup(with: store.state, setting: setting) - gestureCoordinator.setup(setting: setting) - - // Setup page coordinator with initial reading progress if available - if store.readingProgress > 0 { - pageCoordinator.setup( - pageCount: store.gallery.pageCount, - setting: setting, - initialPage: store.readingProgress - ) + // AutoPlay + .onChange(of: store.route) { _, newValue in + Logger.info("store.route changed", context: ["route": newValue]) + if ![.hud, .none].contains(newValue) { + setAutoPlayPolocy(.off) + } + } - // Also update the pager to the correct initial position - let pagerIndex = pageCoordinator.mapToPager(index: store.readingProgress, setting: setting) - page.update(.new(index: pagerIndex)) - } else { - pageCoordinator.setup( - pageCount: store.gallery.pageCount, - setting: setting - ) - } + // LiveText + .onChange(of: liveTextHandler.enablesLiveText) { _, newValue in + Logger.info("liveTextHandler.enablesLiveText changed", context: ["isEnabled": newValue]) + if newValue { store.webImageLoadSuccessIndices.forEach(analyzeImageForLiveText) } + } + .onChange(of: store.webImageLoadSuccessIndices) { _, newValue in + Logger.info("store.webImageLoadSuccessIndices changed", context: [ + "count": store.webImageLoadSuccessIndices.count + ]) + if liveTextHandler.enablesLiveText { + newValue.forEach(analyzeImageForLiveText) + } + } + + // Orientation + .onChange(of: setting.enablesLandscape) { _, newValue in + Logger.info("setting.enablesLandscape changed", context: ["newValue": newValue]) + store.send(.setOrientationPortrait(!newValue)) + } } - private func cleanup() { - viewModel.cleanup() - gestureCoordinator.cleanup() - pageCoordinator.cleanup() + @ViewBuilder private func imageStack(index: Int) -> some View { + let imageStackConfig = store.state.imageContainerConfigs(index: index, setting: setting) + let isDualPage = setting.enablesDualPageMode && setting.readingDirection != .vertical && DeviceUtil.isLandscape + HorizontalImageStack( + index: index, + isDualPage: isDualPage, + isDatabaseLoading: store.databaseLoadingState != .idle, + backgroundColor: backgroundColor, + config: imageStackConfig, + imageURLs: store.imageURLs, + originalImageURLs: store.originalImageURLs, + loadingStates: store.imageURLLoadingStates, + enablesLiveText: liveTextHandler.enablesLiveText, + liveTextGroups: liveTextHandler.liveTextGroups, + focusedLiveTextGroup: liveTextHandler.focusedLiveTextGroup, + liveTextTapAction: liveTextHandler.setFocusedLiveTextGroup, + fetchAction: { store.send(.fetchImageURLs($0)) }, + refetchAction: { store.send(.refetchImageURLs($0)) }, + prefetchAction: { store.send(.prefetchImages($0, setting.prefetchLimit)) }, + loadRetryAction: { store.send(.onWebImageRetry($0)) }, + loadSucceededAction: { store.send(.onWebImageSucceeded($0)) }, + loadFailedAction: { store.send(.onWebImageFailed($0)) }, + copyImageAction: { store.send(.copyImage($0)) }, + saveImageAction: { store.send(.saveImage($0)) }, + shareImageAction: { store.send(.shareImage($0)) } + ) } } -// MARK: - Reading Content View -private struct ReadingContentView: View { - let store: StoreOf - @Binding var setting: Setting - @ObservedObject var viewModel: ReadingViewModel - @ObservedObject var gestureCoordinator: GestureCoordinator - @ObservedObject var pageCoordinator: PageCoordinator - let page: Page - - var body: some View { - Group { - if setting.readingDirection == .vertical { - VerticalReadingView( - store: store, - setting: $setting, - viewModel: viewModel, - gestureCoordinator: gestureCoordinator, - pageCoordinator: pageCoordinator, - page: page - ) - } else { - HorizontalReadingView( - store: store, - setting: $setting, - viewModel: viewModel, - gestureCoordinator: gestureCoordinator, - pageCoordinator: pageCoordinator, - page: page, - onTogglePanel: { store.send(.toggleShowsPanel) } +// MARK: Handler methods +extension ReadingView { + func setPageIndex(sliderValue: Float) { + let newValue = pageHandler.mapToPager( + index: .init(sliderValue), setting: setting + ) + if page.index != newValue { + page.update(.new(index: newValue)) + Logger.info("Pager.update", context: ["update": newValue]) + } + } + func setAutoPlayPolocy(_ policy: AutoPlayPolicy) { + autoPlayHandler.setPolicy(policy, updatePageAction: { + page.update(.next) + Logger.info("Pager.update", context: ["update": "next"]) + }) + } + func analyzeImageForLiveText(index: Int) { + Logger.info("analyzeImageForLiveText", context: ["index": index]) + guard liveTextHandler.liveTextGroups[index] == nil else { + Logger.info("analyzeImageForLiveText duplicated", context: ["index": index]) + return + } + guard let key = store.imageURLs[index]?.absoluteString else { + Logger.info("analyzeImageForLiveText URL not found", context: ["index": index]) + return + } + KingfisherManager.shared.cache.retrieveImage(forKey: key) { result in + switch result { + case .success(let result): + if let image = result.image, let cgImage = image.cgImage { + liveTextHandler.analyzeImage( + cgImage, size: image.size, index: index, recognitionLanguages: + store.galleryDetail?.language.codes + ) + } else { + Logger.info("analyzeImageForLiveText image not found", context: ["index": index]) + } + case .failure(let error): + Logger.info( + "analyzeImageForLiveText failed", + context: [ + "index": index, + "error": error + ] + as [String: Any] ) } } - .scaleEffect(gestureCoordinator.scale, anchor: gestureCoordinator.scaleAnchor) - .offset(gestureCoordinator.offset) - .ignoresSafeArea() - .id(store.databaseLoadingState) - .id(store.forceRefreshID) } } -// MARK: - Vertical Reading View (Fixed for iOS 26) -private struct VerticalReadingView: View { - let store: StoreOf - @Binding var setting: Setting - @ObservedObject var viewModel: ReadingViewModel - @ObservedObject var gestureCoordinator: GestureCoordinator - @ObservedObject var pageCoordinator: PageCoordinator - let page: Page - - var body: some View { - // Fixed vertical scroll implementation for iOS 26 compatibility - ImprovedScrollView( - isScrollEnabled: gestureCoordinator.scale <= 1.0, - page: page, - data: store.state.containerDataSource(setting: setting), - spacing: setting.contentDividerHeight, - gestureCoordinator: gestureCoordinator, - pageCoordinator: pageCoordinator, - setting: setting, - onTogglePanel: { store.send(.toggleShowsPanel) }, - content: { index in - ImageStackView( - index: index, - store: store, - setting: $setting, - viewModel: viewModel, - gestureCoordinator: gestureCoordinator +// MARK: Gesture +extension ReadingView { + var tapGesture: some Gesture { + let singleTap = TapGesture(count: 1) + .onEnded { + gestureHandler.onSingleTapGestureEnded( + readingDirection: setting.readingDirection, + setPageIndexOffsetAction: { + let newValue = page.index + $0 + page.update(.new(index: newValue)) + Logger.info("Pager.update", context: ["update": newValue]) + }, + toggleShowsPanelAction: { store.send(.toggleShowsPanel) } ) } - ) + let doubleTap = TapGesture(count: 2) + .onEnded { + gestureHandler.onDoubleTapGestureEnded( + scaleMaximum: setting.maximumScaleFactor, + doubleTapScale: setting.doubleTapScaleFactor + ) + } + return ExclusiveGesture(doubleTap, singleTap) } -} - -// MARK: - Horizontal Reading View -private struct HorizontalReadingView: View { - let store: StoreOf - @Binding var setting: Setting - @ObservedObject var viewModel: ReadingViewModel - @ObservedObject var gestureCoordinator: GestureCoordinator - @ObservedObject var pageCoordinator: PageCoordinator - let page: Page - let onTogglePanel: () -> Void - - var body: some View { - Pager( - page: page, - data: store.state.containerDataSource(setting: setting), - id: \.self - ) { index in - ImageStackView( - index: index, - store: store, - setting: $setting, - viewModel: viewModel, - gestureCoordinator: gestureCoordinator + var magnificationGesture: some Gesture { + MagnificationGesture() + .onChanged { + gestureHandler.onMagnificationGestureChanged( + value: $0, scaleMaximum: setting.maximumScaleFactor + ) + } + .onEnded { + gestureHandler.onMagnificationGestureEnded( + value: $0, scaleMaximum: setting.maximumScaleFactor + ) + } + } + var dragGesture: some Gesture { + DragGesture(minimumDistance: .zero, coordinateSpace: .local) + .onChanged(gestureHandler.onDragGestureChanged) + .onEnded(gestureHandler.onDragGestureEnded) + } + var controlPanelDismissGesture: some Gesture { + DragGesture().onEnded { + gestureHandler.onControlPanelDismissGestureEnded( + value: $0, dismissAction: { store.send(.onPerformDismiss) } ) } - .horizontal(setting.readingDirection == .rightToLeft ? .endToStart : .startToEnd) - .swipeInteractionArea(.allAvailable) - .allowsDragging(gestureCoordinator.scale == 1) - .readingGestures( - gestureCoordinator: gestureCoordinator, - pageCoordinator: pageCoordinator, - setting: setting, - page: page, - onTogglePanel: onTogglePanel - ) } } -// MARK: - Improved Scroll View (Fixes iOS 26 bug) -private struct ImprovedScrollView: View { - let isScrollEnabled: Bool - let page: Page - let data: [Int] - let spacing: CGFloat - let gestureCoordinator: GestureCoordinator - let pageCoordinator: PageCoordinator - let setting: Setting - let onTogglePanel: () -> Void - let content: (Int) -> Content - - @State private var performingChanges = false - @State private var scrollTarget: Int? - @State private var currentVisibleIndex: Int = 0 +// MARK: HorizontalImageStack +private struct HorizontalImageStack: View { + private let index: Int + private let isDualPage: Bool + private let isDatabaseLoading: Bool + private let backgroundColor: Color + private let config: ImageStackConfig + private let imageURLs: [Int: URL] + private let originalImageURLs: [Int: URL] + private let loadingStates: [Int: LoadingState] + private let enablesLiveText: Bool + private let liveTextGroups: [Int: [LiveTextGroup]] + private let focusedLiveTextGroup: LiveTextGroup? + private let liveTextTapAction: (LiveTextGroup) -> Void + private let fetchAction: (Int) -> Void + private let refetchAction: (Int) -> Void + private let prefetchAction: (Int) -> Void + private let loadRetryAction: (Int) -> Void + private let loadSucceededAction: (Int) -> Void + private let loadFailedAction: (Int) -> Void + private let copyImageAction: (URL) -> Void + private let saveImageAction: (URL) -> Void + private let shareImageAction: (URL) -> Void init( - isScrollEnabled: Bool, - page: Page, - data: [Int], - spacing: CGFloat, - gestureCoordinator: GestureCoordinator, - pageCoordinator: PageCoordinator, - setting: Setting, - onTogglePanel: @escaping () -> Void, - @ViewBuilder content: @escaping (Int) -> Content + index: Int, isDualPage: Bool, isDatabaseLoading: Bool, backgroundColor: Color, + config: ImageStackConfig, imageURLs: [Int: URL], originalImageURLs: [Int: URL], + loadingStates: [Int: LoadingState], enablesLiveText: Bool, + liveTextGroups: [Int: [LiveTextGroup]], focusedLiveTextGroup: LiveTextGroup?, + liveTextTapAction: @escaping (LiveTextGroup) -> Void, + fetchAction: @escaping (Int) -> Void, + refetchAction: @escaping (Int) -> Void, prefetchAction: @escaping (Int) -> Void, + loadRetryAction: @escaping (Int) -> Void, loadSucceededAction: @escaping (Int) -> Void, + loadFailedAction: @escaping (Int) -> Void, copyImageAction: @escaping (URL) -> Void, + saveImageAction: @escaping (URL) -> Void, shareImageAction: @escaping (URL) -> Void ) { - self.isScrollEnabled = isScrollEnabled - self.page = page - self.data = data - self.spacing = spacing - self.gestureCoordinator = gestureCoordinator - self.pageCoordinator = pageCoordinator - self.setting = setting - self.onTogglePanel = onTogglePanel - self.content = content + self.index = index + self.isDualPage = isDualPage + self.isDatabaseLoading = isDatabaseLoading + self.backgroundColor = backgroundColor + self.config = config + self.imageURLs = imageURLs + self.originalImageURLs = originalImageURLs + self.loadingStates = loadingStates + self.enablesLiveText = enablesLiveText + self.liveTextGroups = liveTextGroups + self.focusedLiveTextGroup = focusedLiveTextGroup + self.liveTextTapAction = liveTextTapAction + self.fetchAction = fetchAction + self.refetchAction = refetchAction + self.prefetchAction = prefetchAction + self.loadRetryAction = loadRetryAction + self.loadSucceededAction = loadSucceededAction + self.loadFailedAction = loadFailedAction + self.copyImageAction = copyImageAction + self.saveImageAction = saveImageAction + self.shareImageAction = shareImageAction } var body: some View { - ScrollViewReader { proxy in - ScrollView(.vertical, showsIndicators: false) { - LazyVStack(spacing: spacing) { - ForEach(data, id: \.self) { index in - content(index) - .id(index + 1) // Use 1-based indexing for scroll target - .background( - GeometryReader { geometry in - Color.clear - .preference( - key: ScrollOffsetPreferenceKey.self, - value: [index: ScrollOffsetData( - index: index, - frame: geometry.frame(in: .named("ScrollView")) - )] - ) - } - ) - } - } - .onAppear { - scrollToCurrentPage(proxy: proxy) + HStack(spacing: 0) { + if config.isFirstAvailable { + imageContainer(index: config.firstIndex) + } + if config.isSecondAvailable { + imageContainer(index: config.secondIndex) + } + } + } + + func imageContainer(index: Int) -> some View { + ImageContainer( + index: index, + imageURL: imageURLs[index], + loadingState: loadingStates[index] ?? .idle, + isDualPage: isDualPage, + backgroundColor: backgroundColor, + enablesLiveText: enablesLiveText, + liveTextGroups: liveTextGroups[index] ?? [], + focusedLiveTextGroup: focusedLiveTextGroup, + liveTextTapAction: liveTextTapAction, + refetchAction: refetchAction, + loadRetryAction: loadRetryAction, + loadSucceededAction: loadSucceededAction, + loadFailedAction: loadFailedAction + ) + .onAppear { + if !isDatabaseLoading { + if imageURLs[index] == nil { + fetchAction(index) } + prefetchAction(index) } - // Fixed scrollDisabled implementation for iOS 26 - .scrollDisabled(!isScrollEnabled) - .coordinateSpace(name: "ScrollView") - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { preferences in - updateCurrentVisibleIndex(from: preferences) + } + .contextMenu { contextMenuItems(index: index) } + } + @ViewBuilder private func contextMenuItems(index: Int) -> some View { + Button { + refetchAction(index) + } label: { + Label(L10n.Localizable.ReadingView.ContextMenu.Button.reload, systemSymbol: .arrowCounterclockwise) + } + if let imageURL = imageURLs[index] { + Button { + copyImageAction(imageURL) + } label: { + Label(L10n.Localizable.ReadingView.ContextMenu.Button.copy, systemSymbol: .plusSquareOnSquare) } - .readingGestures( - gestureCoordinator: gestureCoordinator, - pageCoordinator: pageCoordinator, - setting: setting, - page: page, - onTogglePanel: onTogglePanel - ) - .onChange(of: page.index) { _, newValue in - scrollToPage(newValue, proxy: proxy) + Button { + saveImageAction(imageURL) + } label: { + Label(L10n.Localizable.ReadingView.ContextMenu.Button.save, systemSymbol: .squareAndArrowDown) } - .onChange(of: isScrollEnabled) { _, newValue in - // Re-enable/disable scrolling based on zoom level - if newValue && scrollTarget != nil { - if let target = scrollTarget { - withAnimation(.easeInOut(duration: 0.3)) { - proxy.scrollTo(target, anchor: .center) - } - scrollTarget = nil - } + if let originalImageURL = originalImageURLs[index] { + Button { + saveImageAction(originalImageURL) + } label: { + Label( + L10n.Localizable.ReadingView.ContextMenu.Button.saveOriginal, + systemSymbol: .squareAndArrowDownOnSquare + ) } } + Button { + shareImageAction(imageURL) + } label: { + Label(L10n.Localizable.ReadingView.ContextMenu.Button.share, systemSymbol: .squareAndArrowUp) + } } } +} - private func updateCurrentVisibleIndex(from preferences: [Int: ScrollOffsetData]) { - guard !performingChanges else { return } +// MARK: ImageContainer +private struct ImageContainer: View { + private var width: CGFloat { + DeviceUtil.windowW / (isDualPage ? 2 : 1) + } + private var height: CGFloat { + width / Defaults.ImageSize.contentAspect + } - // Find the most visible item (closest to center of screen) - let screenCenter = UIScreen.main.bounds.height / 2 - var mostVisibleIndex = 0 - var maxVisibility: CGFloat = 0 + private let index: Int + private let imageURL: URL? + private let loadingState: LoadingState + private let isDualPage: Bool + private let backgroundColor: Color + private let enablesLiveText: Bool + private let liveTextGroups: [LiveTextGroup] + private let focusedLiveTextGroup: LiveTextGroup? + private let liveTextTapAction: (LiveTextGroup) -> Void + private let refetchAction: (Int) -> Void + private let loadRetryAction: (Int) -> Void + private let loadSucceededAction: (Int) -> Void + private let loadFailedAction: (Int) -> Void - for (_, item) in preferences { - let itemCenter = item.frame.midY - let distanceFromCenter = abs(itemCenter - screenCenter) - let visibility = max(0, 1 - distanceFromCenter / screenCenter) + init( + index: Int, imageURL: URL?, + loadingState: LoadingState, + isDualPage: Bool, + backgroundColor: Color, + enablesLiveText: Bool, + liveTextGroups: [LiveTextGroup], + focusedLiveTextGroup: LiveTextGroup?, + liveTextTapAction: @escaping (LiveTextGroup) -> Void, + refetchAction: @escaping (Int) -> Void, + loadRetryAction: @escaping (Int) -> Void, + loadSucceededAction: @escaping (Int) -> Void, + loadFailedAction: @escaping (Int) -> Void + ) { + self.index = index + self.imageURL = imageURL + self.loadingState = loadingState + self.isDualPage = isDualPage + self.backgroundColor = backgroundColor + self.enablesLiveText = enablesLiveText + self.liveTextGroups = liveTextGroups + self.focusedLiveTextGroup = focusedLiveTextGroup + self.liveTextTapAction = liveTextTapAction + self.refetchAction = refetchAction + self.loadRetryAction = loadRetryAction + self.loadSucceededAction = loadSucceededAction + self.loadFailedAction = loadFailedAction + } - if visibility > maxVisibility { - maxVisibility = visibility - mostVisibleIndex = item.index - } + private func placeholder(_ progress: Progress) -> some View { + Placeholder(style: .progress( + pageNumber: index, progress: progress, + isDualPage: isDualPage, backgroundColor: backgroundColor + )) + .frame(width: width, height: height) + } + @ViewBuilder private func image(url: URL?) -> some View { + if url?.isGIF != true { + KFImage(url) + .placeholder(placeholder) + .defaultModifier(withRoundedCorners: false) + .onSuccess(onSuccess).onFailure(onFailure) + } else { + KFAnimatedImage(url) + .placeholder(placeholder).fade(duration: 0.25) + .onSuccess(onSuccess).onFailure(onFailure) } + } - // Update page index if it changed significantly - if mostVisibleIndex != currentVisibleIndex && maxVisibility > 0.5 { - currentVisibleIndex = mostVisibleIndex - let newPageIndex = mostVisibleIndex - if page.index != newPageIndex { - performingChanges = true - page.update(.new(index: newPageIndex)) - - // Reset performing changes after a delay - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - performingChanges = false + var body: some View { + if loadingState == .idle { + image(url: imageURL).scaledToFit().overlay( + LiveTextView( + liveTextGroups: liveTextGroups, + focusedLiveTextGroup: focusedLiveTextGroup, + tapAction: liveTextTapAction + ) + .opacity(enablesLiveText ? 1 : 0) + ) + } else { + ZStack { + backgroundColor + VStack { + Text(String(index)).font(.largeTitle.bold()) + .foregroundColor(.gray).padding(.bottom, 30) + ZStack { + Button(action: reloadImage) { + Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) + } + .font(.system(size: 30, weight: .medium)).foregroundColor(.gray) + .opacity(loadingState == .loading ? 0 : 1) + ProgressView().opacity(loadingState == .loading ? 1 : 0) + } } - - Logger.info("Updated page index from scroll", context: [ - "newPageIndex": newPageIndex, - "visibility": maxVisibility - ]) } + .frame(width: width, height: height) } } - - private func handleTap(index: Int) { - performingChanges = true - page.update(.new(index: index - 1)) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - performingChanges = false + private func reloadImage() { + if let error = loadingState.failed { + if case .webImageFailed = error { + loadRetryAction(index) + } else { + refetchAction(index) + } } } - - private func scrollToCurrentPage(proxy: ScrollViewProxy) { - let targetId = page.index + 1 - DispatchQueue.main.async { - proxy.scrollTo(targetId, anchor: .center) - } + private func onSuccess(_: RetrieveImageResult) { + loadSucceededAction(index) } - - private func scrollToPage(_ pageIndex: Int, proxy: ScrollViewProxy) { - guard !performingChanges else { return } - - let targetId = pageIndex + 1 - if isScrollEnabled { - // Do not apply animation here unless you can let the method - // `updateCurrentVisibleIndex` stop screwing up the slider value. - proxy.scrollTo(targetId, anchor: .center) - } else { - // Store target for when scrolling is re-enabled - scrollTarget = targetId + private func onFailure(_: KingfisherError) { + if imageURL != nil { + loadFailedAction(index) } } } -// MARK: - Scroll Position Tracking -private struct ScrollOffsetData: Equatable { - let index: Int - let frame: CGRect +// MARK: Definition +struct ImageStackConfig { + let firstIndex: Int + let secondIndex: Int + let isFirstAvailable: Bool + let isSecondAvailable: Bool } -private struct ScrollOffsetPreferenceKey: PreferenceKey { - static var defaultValue: [Int: ScrollOffsetData] = [:] +enum AutoPlayPolicy: Int, CaseIterable, Identifiable { + var id: Int { rawValue } - static func reduce(value: inout [Int: ScrollOffsetData], nextValue: () -> [Int: ScrollOffsetData]) { - value.merge(nextValue()) { _, new in new } - } + case off = -1 + case sec1 = 1 + case sec2 = 2 + case sec3 = 3 + case sec4 = 4 + case sec5 = 5 } -// MARK: - Reading Controls Overlay -private struct ReadingControlsOverlay: View { - let store: StoreOf - @Binding var setting: Setting - @ObservedObject var viewModel: ReadingViewModel - @ObservedObject var pageCoordinator: PageCoordinator - @ObservedObject var gestureCoordinator: GestureCoordinator - let page: Page - - var body: some View { - ReadingControlPanel( - showsPanel: Binding( - get: { store.showsPanel }, - set: { store.send(.binding(.set(\.showsPanel, $0))) } - ), - showsSliderPreview: Binding( - get: { store.showsSliderPreview }, - set: { store.send(.binding(.set(\.showsSliderPreview, $0))) } - ), - sliderValue: $pageCoordinator.sliderValue, - setting: $setting, - enablesLiveText: $viewModel.enablesLiveText, - autoPlayPolicy: .init( - get: { viewModel.autoPlayPolicy }, - set: { viewModel.setAutoPlayPolicy($0, pageUpdater: { page.update(.next) }) } - ), - range: 1...Float(store.gallery.pageCount), - previewURLs: store.previewURLs, - dismissGesture: createDismissGesture(), - dismissAction: { store.send(.onPerformDismiss) }, - navigateSettingAction: { store.send(.setNavigation(.readingSetting())) }, - reloadAllImagesAction: { store.send(.reloadAllWebImages) }, - retryAllFailedImagesAction: { store.send(.retryAllFailedWebImages) }, - fetchPreviewURLsAction: { store.send(.fetchPreviewURLs($0)) } - ) - } - - private func createDismissGesture() -> some Gesture { - DragGesture() - .onEnded { value in - gestureCoordinator.handleControlPanelDismiss( - value: value, - dismissAction: { store.send(.onPerformDismiss) } - ) - } +extension AutoPlayPolicy { + var value: String { + switch self { + case .off: + return L10n.Localizable.Enum.AutoPlayPolicy.Value.off + default: + return L10n.Localizable.Common.Value.seconds("\(rawValue)") + } } } -// MARK: - Preview struct ReadingView_Previews: PreviewProvider { static var previews: some View { NavigationView { Text("") .fullScreenCover(isPresented: .constant(true)) { ReadingView( - store: .init( - initialState: .init(gallery: .empty), - reducer: ReadingReducer.init - ), + store: .init(initialState: .init(gallery: .empty), reducer: ReadingReducer.init), gid: .init(), setting: .constant(.init()), blurRadius: 0 diff --git a/EhPanda/View/Reading/Support/AdvancedList.swift b/EhPanda/View/Reading/Support/AdvancedList.swift index 10ec2890..24ea6fca 100644 --- a/EhPanda/View/Reading/Support/AdvancedList.swift +++ b/EhPanda/View/Reading/Support/AdvancedList.swift @@ -6,15 +6,10 @@ import SwiftUI import SwiftUIPager -/// Improved vertical list for reading view with iOS 26 scrolling fix struct AdvancedList: View where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { + @State var performingChanges = false - // MARK: - State - @State private var performingChanges = false - @State private var scrollTarget: Element? - - // MARK: - Properties private let pagerModel: Page private let data: [Element] private let id: KeyPath @@ -22,13 +17,9 @@ where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { private let gesture: G private let content: (Element) -> PageView - // MARK: - Initialization init( - page: Page, - data: Data, - id: KeyPath, - spacing: CGFloat, - gesture: G, + page: Page, data: Data, + id: KeyPath, spacing: CGFloat, gesture: G, @ViewBuilder content: @escaping (Element) -> PageView ) where Data.Index == Int, Data.Element == Element { self.pagerModel = page @@ -39,170 +30,43 @@ where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { self.content = content } - // MARK: - Body var body: some View { ScrollViewReader { proxy in - ScrollView(.vertical, showsIndicators: false) { + ScrollView(showsIndicators: false) { LazyVStack(spacing: spacing) { - ForEach(data, id: id) { element in - contentWithGestures(for: element) - .id(element[keyPath: id]) + ForEach(data, id: id) { index in + let longPress = longPressGesture(index: index) + let gestures = longPress.simultaneously(with: gesture) + content(index).gesture(gestures) } } - .onAppear { - initialScrollToPage(proxy: proxy) - } + .onAppear { tryScrollTo(id: pagerModel.index + 1, proxy: proxy) } } - // iOS 26 compatible scroll handling - .coordinateSpace(name: "ScrollView") .onChange(of: pagerModel.index) { _, newValue in - handlePageChange(newValue: newValue, proxy: proxy) - } - .onChange(of: scrollTarget) { _, newValue in - if let target = newValue { - scrollToTarget(target, proxy: proxy) - } + tryScrollTo(id: newValue + 1, proxy: proxy) } } } - // MARK: - Content with Gestures - @ViewBuilder - private func contentWithGestures(for element: Element) -> some View { - let longPress = createLongPressGesture(for: element) - let combinedGestures = longPress.simultaneously(with: gesture) - - content(element) - .gesture(combinedGestures) - } - - // MARK: - Gesture Creation - private func createLongPressGesture(for element: Element) -> some Gesture { - LongPressGesture(minimumDuration: 0, maximumDistance: .infinity) + private func longPressGesture(index: Element) -> some Gesture { + // Setting `minimumDuration` to zero will block ScrollView interaction + LongPressGesture(minimumDuration: 0.5, maximumDistance: .infinity) .onEnded { _ in - handleLongPress(for: element) - } - } - - // MARK: - Event Handlers - private func handleLongPress(for element: Element) { - guard let index = element as? Int else { return } - - Logger.info("Long press detected", context: ["element": index]) - - performingChanges = true - pagerModel.update(.new(index: index - 1)) - - // Reset performing changes after a delay - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - performingChanges = false - } - } - - private func initialScrollToPage(proxy: ScrollViewProxy) { - guard !data.isEmpty else { return } - - let targetElement = getElementForPageIndex(pagerModel.index) - scrollToElementSafely(targetElement, proxy: proxy, animated: false) - } - - private func handlePageChange(newValue: Int, proxy: ScrollViewProxy) { - guard !performingChanges else { return } - - Logger.info("Page changed in AdvancedList", context: [ - "newPageIndex": newValue, - "dataCount": data.count - ]) - - let targetElement = getElementForPageIndex(newValue) - scrollToElementSafely(targetElement, proxy: proxy, animated: true) - } - - private func scrollToTarget(_ target: Element, proxy: ScrollViewProxy) { - scrollToElementSafely(target, proxy: proxy, animated: true) - scrollTarget = nil - } - - // MARK: - Helper Methods - private func getElementForPageIndex(_ pageIndex: Int) -> Element? { - let safeIndex = max(0, min(pageIndex, data.count - 1)) - guard safeIndex < data.count else { return nil } - return data[safeIndex] - } - - private func scrollToElementSafely( - _ element: Element?, - proxy: ScrollViewProxy, - animated: Bool - ) { - guard let element = element else { return } - - let elementId = element[keyPath: id] - - if animated { - withAnimation(.easeInOut(duration: 0.3)) { - proxy.scrollTo(elementId, anchor: .center) - } - } else { - // Use dispatchMainSync for immediate scrolling without animation - AppUtil.dispatchMainSync { - proxy.scrollTo(elementId, anchor: .center) - } - } - - Logger.info("Scrolled to element", context: [ - "elementId": "\(elementId)", - "animated": animated - ]) - } -} - -// MARK: - iOS 26 Compatibility Extensions - -extension AdvancedList { - /// Creates a version with enhanced scroll compatibility - func withEnhancedScrolling() -> some View { - self - .scrollContentBackground(.hidden) - .scrollIndicators(.hidden) - } - - /// Handles scroll position restoration for iOS 26 - func withScrollRestoration() -> some View { - self - .onAppear { - // Ensure proper scroll position on appear - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if let currentElement = getElementForPageIndex(pagerModel.index) { - scrollTarget = currentElement + if let index = index as? Int { + performingChanges = true + pagerModel.update(.new(index: index - 1)) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + performingChanges = false } } } } -} - -// MARK: - Preview -struct AdvancedList_Previews: PreviewProvider { - static var previews: some View { - let page = Page.first() - let sampleData = Array(1...10) - AdvancedList( - page: page, - data: sampleData, - id: \.self, - spacing: 10, - gesture: TapGesture() - ) { item in - Rectangle() - .fill(Color.blue.opacity(0.3)) - .frame(height: 200) - .overlay( - Text("\(item)") - .font(.title) - .foregroundColor(.primary) - ) + private func tryScrollTo(id: Int, proxy: ScrollViewProxy) { + if !performingChanges { + AppUtil.dispatchMainSync { + proxy.scrollTo(id, anchor: .center) + } } - .previewLayout(.sizeThatFits) } } diff --git a/EhPanda/View/Reading/Support/AutoPlayHandler.swift b/EhPanda/View/Reading/Support/AutoPlayHandler.swift new file mode 100644 index 00000000..660f96d9 --- /dev/null +++ b/EhPanda/View/Reading/Support/AutoPlayHandler.swift @@ -0,0 +1,33 @@ +// +// AutoPlayHandler.swift +// EhPanda +// + +import SwiftUI + +final class AutoPlayHandler: ObservableObject { + @Published var policy: AutoPlayPolicy = .off + private var timer: Timer? + + deinit { + invalidate() + } + + func invalidate() { + Logger.info("invalidate") + timer?.invalidate() + } + + func setPolicy(_ policy: AutoPlayPolicy, updatePageAction: @escaping () -> Void) { + Logger.info("setPolicy", context: ["policy": policy]) + self.policy = policy + timer?.invalidate() + let timeInterval = TimeInterval(policy.rawValue) + if timeInterval > 0 { + timer = .scheduledTimer( + withTimeInterval: timeInterval, repeats: true, + block: { _ in updatePageAction() } + ) + } + } +} diff --git a/EhPanda/View/Reading/Support/ControlPanel.swift b/EhPanda/View/Reading/Support/ControlPanel.swift index ecb04484..f0fcdf0b 100644 --- a/EhPanda/View/Reading/Support/ControlPanel.swift +++ b/EhPanda/View/Reading/Support/ControlPanel.swift @@ -25,15 +25,9 @@ struct ControlPanel: View { private let fetchPreviewURLsAction: (Int) -> Void init( - showsPanel: Binding, - showsSliderPreview: Binding, - sliderValue: Binding, - setting: Binding, - enablesLiveText: Binding, - autoPlayPolicy: Binding, - range: ClosedRange, - previewURLs: [Int: URL], - dismissGesture: G, + showsPanel: Binding, showsSliderPreview: Binding, sliderValue: Binding, + setting: Binding, enablesLiveText: Binding, autoPlayPolicy: Binding, + range: ClosedRange, previewURLs: [Int: URL], dismissGesture: G, dismissAction: @escaping () -> Void, navigateSettingAction: @escaping () -> Void, reloadAllImagesAction: @escaping () -> Void, @@ -73,18 +67,13 @@ struct ControlPanel: View { retryAllFailedImagesAction: retryAllFailedImagesAction ) .offset(y: showsPanel ? 0 : -50) - Spacer() - if range.upperBound > range.lowerBound { LowerPanel( showsSliderPreview: $showsSliderPreview, - sliderValue: $sliderValue, - previewURLs: previewURLs, - range: range, + sliderValue: $sliderValue, previewURLs: previewURLs, range: range, isReversed: setting.readingDirection == .rightToLeft, - dismissGesture: dismissGesture, - dismissAction: dismissAction, + dismissGesture: dismissGesture, dismissAction: dismissAction, fetchPreviewURLsAction: fetchPreviewURLsAction ) .animation(.default, value: showsSliderPreview) @@ -129,29 +118,27 @@ private struct UpperPanel: View { var body: some View { HStack { - // Liquid Glass Dismiss Button - Button(action: dismissAction) { - Image(systemSymbol: .xmark) + HStack(spacing: 16) { + Button(action: dismissAction) { + Image(systemSymbol: .xmark) + .font(.title2) + .frame(width: 44, height: 44) + } + .glassEffect(.regular.interactive()) + + Text(title) .font(.title2) - .foregroundColor(.primary) - .frame(width: 44, height: 44) + .fontWeight(.bold) + .monospacedDigit() + .lineLimit(1) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .glassEffect(.regular.interactive()) } - .glassEffect(.regular.interactive()) - - Spacer() - - // Page Number Display in Liquid Glass Bubble - Text(title) - .bold() - .lineLimit(1) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .glassEffect() Spacer() - // Toolbar Grouped in Liquid Glass Container - HStack(spacing: 16) { + HStack(spacing: 20) { Button { enablesLiveText.toggle() } label: { @@ -221,10 +208,11 @@ private struct UpperPanel: View { .buttonStyle(.borderless) .font(.title2) } - .padding(.vertical, 8) - .padding(.horizontal, 16) + .padding(.vertical, 12) + .padding(.horizontal, 20) .glassEffect(.regular.interactive()) } + .foregroundStyle(.primary) .padding(.horizontal, 20) } } @@ -258,7 +246,6 @@ private struct LowerPanel: View { var body: some View { VStack(spacing: 30) { - // Dismiss Button Button(action: dismissAction) { Image(systemSymbol: .xmark) .foregroundColor(.primary) @@ -269,7 +256,6 @@ private struct LowerPanel: View { .gesture(dismissGesture) .opacity(showsSliderPreview ? 0 : 1) - // Slider in Liquid Glass Bubble VStack(spacing: 0) { SliderPreivew( showsSliderPreview: $showsSliderPreview, @@ -291,7 +277,6 @@ private struct LowerPanel: View { in: range, onEditingChanged: { if !$0 { showsSliderPreview = false } } ) - // wtaf is happening here? .frame(width: DeviceUtil.windowW * 0.6) .rotationEffect(.init(degrees: isReversed ? 180 : 0)) .simultaneousGesture( diff --git a/EhPanda/View/Reading/Support/GestureCoordinator.swift b/EhPanda/View/Reading/Support/GestureCoordinator.swift deleted file mode 100644 index 62017f4a..00000000 --- a/EhPanda/View/Reading/Support/GestureCoordinator.swift +++ /dev/null @@ -1,372 +0,0 @@ -// -// GestureCoordinator.swift -// EhPanda -// - -import SwiftUI -import SwiftUIPager - -// MARK: - Gesture Coordinator -final class GestureCoordinator: ObservableObject { - // MARK: - Published Properties - @Published var scaleAnchor: UnitPoint = .center - @Published var scale: Double = 1.0 - @Published var offset: CGSize = .zero - @Published var dragStartOffset: CGSize = .zero - - // MARK: - Private Properties - private var baseScale: Double = 1.0 - private var baseOffset: CGSize = .zero - private var currentPanOffset: CGSize = .zero - private var setting: Setting = .init() - - // MARK: - Configuration - private var gestureConfig: GestureConfiguration = .init() - - // MARK: - Setup - func setup(setting: Setting) { - self.setting = setting - gestureConfig = GestureConfiguration(setting: setting) - } - - func cleanup() { - resetToDefaults() - } - - private func resetToDefaults() { - scale = 1.0 - offset = .zero - scaleAnchor = .center - baseScale = 1.0 - baseOffset = .zero - } - - // MARK: - Gesture Handlers - - /// Handles single tap gestures for page navigation or panel toggling - func handleSingleTap( - readingDirection: ReadingDirection, - onPageNavigation: @escaping (Int) -> Void, - onTogglePanel: @escaping () -> Void - ) { - Logger.info("Handle single tap", context: ["readingDirection": readingDirection]) - - // For vertical reading, always toggle panel - guard readingDirection != .vertical, - let touchPoint = TouchHandler.shared.currentPoint - else { - onTogglePanel() - return - } - - let tapRegion = determineTapRegion(point: touchPoint) - handleTapRegion( - tapRegion, - readingDirection: readingDirection, - onPageNavigation: onPageNavigation, - onTogglePanel: onTogglePanel - ) - } - - /// Handles double tap gestures for zoom - func handleDoubleTap() { - Logger.info("Handle double tap", context: [ - "currentScale": scale, - "doubleTapScale": setting.doubleTapScaleFactor - ]) - - let targetScale = scale == 1.0 ? setting.doubleTapScaleFactor : 1.0 - - if let touchPoint = TouchHandler.shared.currentPoint { - updateScaleAnchor(for: touchPoint) - } - - withAnimation(.easeInOut(duration: 0.25)) { - scale = targetScale - if targetScale == 1.0 { - offset = .zero - scaleAnchor = .center - } - } - - baseScale = scale - baseOffset = offset - } - - /// Handles magnification (pinch) gestures - func handleMagnificationChanged(value: Double) { - Logger.info("Handle magnification changed", context: ["value": value]) - - if value == 1.0 { - baseScale = scale - } - - if let touchPoint = TouchHandler.shared.currentPoint { - updateScaleAnchor(for: touchPoint) - } - - let newScale = min(max(value * baseScale, 1.0), setting.maximumScaleFactor) - scale = newScale - constrainOffset() - } - - func handleMagnificationEnded(value: Double) { - Logger.info("Handle magnification ended", context: ["value": value]) - - let finalScale = min(max(value * baseScale, 1.0), setting.maximumScaleFactor) - - // Snap to 1.0 if very close - if abs(finalScale - 1.0) < 0.05 { - withAnimation(.easeOut(duration: 0.2)) { - scale = 1.0 - offset = .zero - scaleAnchor = .center - } - } else { - scale = finalScale - // Apply constraints after scale change to ensure proper bounds - constrainOffset() - } - - baseScale = scale - baseOffset = offset - } - - /// Handles drag gestures for panning when zoomed - func handleDragChanged(value: DragGesture.Value) { - guard scale > 1.0 else { return } - - Logger.info("Handle drag changed", context: [ - "translation": value.translation, - "scale": scale, - "currentPanOffset": currentPanOffset - ]) - - // Add high sensitivity multiplier for more responsive movement - let sensitivity: CGFloat = 2.0 - let adjustedTranslation = CGSize( - width: value.translation.width * sensitivity, - height: value.translation.height * sensitivity - ) - - // Update current pan offset - currentPanOffset = adjustedTranslation - - // Calculate total offset (base + current pan) - let totalOffset = CGSize( - width: baseOffset.width + currentPanOffset.width, - height: baseOffset.height + currentPanOffset.height - ) - - // Apply boundary constraints to prevent dragging beyond image edges - offset = constrainOffset(totalOffset) - - Logger.info("Offset updated", context: [ - "adjustedTranslation": adjustedTranslation, - "currentPanOffset": currentPanOffset, - "totalOffset": totalOffset, - "constrainedOffset": offset - ]) - } - - func handleDragStarted() { - guard scale > 1.0 else { return } - Logger.info("Handle drag started") - currentPanOffset = .zero - } - - func handleDragEnded(value: DragGesture.Value) { - guard scale > 1.0 else { return } - Logger.info("Handle drag ended") - - // Ensure the final position is properly constrained - let finalOffset = constrainOffset(offset) - offset = finalOffset - - // Update base offset with final constrained position - baseOffset = finalOffset - currentPanOffset = .zero - } - - /// Handles control panel dismiss gesture - func handleControlPanelDismiss(value: DragGesture.Value, dismissAction: @escaping () -> Void) { - Logger.info("Handle control panel dismiss", context: ["translation": value.translation]) - - if value.predictedEndTranslation.height > 30 { - dismissAction() - } - } - - // MARK: - Private Helper Methods - - private func determineTapRegion(point: CGPoint) -> TapRegion { - let screenWidth = DeviceUtil.absWindowW - let leftThreshold = screenWidth * 0.2 - let rightThreshold = screenWidth * 0.8 - - if point.x < leftThreshold { - return .left - } else if point.x > rightThreshold { - return .right - } else { - return .center - } - } - - private func handleTapRegion( - _ region: TapRegion, - readingDirection: ReadingDirection, - onPageNavigation: @escaping (Int) -> Void, - onTogglePanel: @escaping () -> Void - ) { - let isRightToLeft = readingDirection == .rightToLeft - - switch region { - case .left: - onPageNavigation(isRightToLeft ? 1 : -1) - case .right: - onPageNavigation(isRightToLeft ? -1 : 1) - case .center: - onTogglePanel() - } - } - - private func updateScaleAnchor(for point: CGPoint) { - let normalizedX = min(1, max(0, point.x / DeviceUtil.absWindowW)) - let normalizedY = min(1, max(0, point.y / DeviceUtil.absWindowH)) - scaleAnchor = UnitPoint(x: normalizedX, y: normalizedY) - } - - @discardableResult - private func constrainOffset(_ newOffset: CGSize? = nil) -> CGSize { - let targetOffset = newOffset ?? offset - - // Calculate the maximum allowed offset based on scale and screen size - let screenWidth = DeviceUtil.absWindowW - let screenHeight = DeviceUtil.absWindowH - - // When scaled, the image is larger than the screen, so we need to constrain - // the offset to keep the image content visible - let maxOffsetX = screenWidth * (scale - 1) / 2 - let maxOffsetY = screenHeight * (scale - 1) / 2 - - // Apply constraints to keep the image within bounds - let constrainedWidth = min(max(targetOffset.width, -maxOffsetX), maxOffsetX) - let constrainedHeight = min(max(targetOffset.height, -maxOffsetY), maxOffsetY) - - let constrained = CGSize(width: constrainedWidth, height: constrainedHeight) - - if newOffset == nil { - offset = constrained - } - - return constrained - } -} - -// MARK: - Supporting Types - -private enum TapRegion { - case left, center, right -} - -private struct GestureConfiguration { - let tapRegionThreshold: Double - let snapToOneThreshold: Double - let panVelocityThreshold: Double - - init(setting: Setting? = nil) { - self.tapRegionThreshold = 0.2 - self.snapToOneThreshold = 0.05 - self.panVelocityThreshold = 100.0 - } -} - -// MARK: - View Extensions for Gesture Support - -extension View { - func readingGestures( - gestureCoordinator: GestureCoordinator, - pageCoordinator: PageCoordinator, - setting: Setting, - page: Page, - onTogglePanel: @escaping () -> Void - ) -> some View { - let tapGesture = createTapGesture( - gestureCoordinator: gestureCoordinator, - pageCoordinator: pageCoordinator, - setting: setting, - page: page, - onTogglePanel: onTogglePanel - ) - - let magnificationGesture = createMagnificationGesture( - gestureCoordinator: gestureCoordinator - ) - - let dragGesture = createDragGesture( - gestureCoordinator: gestureCoordinator - ) - - return self - .gesture(dragGesture, isEnabled: gestureCoordinator.scale > 1) - .simultaneousGesture( - tapGesture, - isEnabled: gestureCoordinator.scale > 1 - ) - .gesture(tapGesture, isEnabled: gestureCoordinator.scale == 1) - .gesture(magnificationGesture) - } - - private func createTapGesture( - gestureCoordinator: GestureCoordinator, - pageCoordinator: PageCoordinator, - setting: Setting, - page: Page, - onTogglePanel: @escaping () -> Void - ) -> some Gesture { - let singleTap = TapGesture(count: 1) - .onEnded { - gestureCoordinator.handleSingleTap( - readingDirection: setting.readingDirection, - onPageNavigation: { offset in - let newIndex = page.index + offset - page.update(.new(index: newIndex)) - Logger.info("Page navigation", context: ["newIndex": newIndex]) - }, - onTogglePanel: onTogglePanel - ) - } - - let doubleTap = TapGesture(count: 2) - .onEnded { - gestureCoordinator.handleDoubleTap() - } - - return ExclusiveGesture(doubleTap, singleTap) - } - - private func createMagnificationGesture( - gestureCoordinator: GestureCoordinator - ) -> some Gesture { - MagnificationGesture() - .onChanged { value in - gestureCoordinator.handleMagnificationChanged(value: value) - } - .onEnded { value in - gestureCoordinator.handleMagnificationEnded(value: value) - } - } - - private func createDragGesture( - gestureCoordinator: GestureCoordinator - ) -> some Gesture { - DragGesture(minimumDistance: 0, coordinateSpace: .global) - .onChanged { value in - gestureCoordinator.handleDragChanged(value: value) - } - .onEnded { value in - gestureCoordinator.handleDragEnded(value: value) - } - } -} diff --git a/EhPanda/View/Reading/Support/GestureHandler.swift b/EhPanda/View/Reading/Support/GestureHandler.swift new file mode 100644 index 00000000..c3b88c58 --- /dev/null +++ b/EhPanda/View/Reading/Support/GestureHandler.swift @@ -0,0 +1,129 @@ +// +// GestureHandler.swift +// EhPanda +// + +import SwiftUI + +final class GestureHandler: ObservableObject { + @Published var scaleAnchor: UnitPoint = .center + @Published var scale: Double = 1 + @Published var offset: CGSize = .zero + @Published private var baseScale: Double = 1 + @Published private var newOffset: CGSize = .zero + + private func edgeWidth(x: Double) -> Double { + let marginW = DeviceUtil.absWindowW * (scale - 1) / 2 + let leadingMargin = scaleAnchor.x / 0.5 * marginW + let trailingMargin = (1 - scaleAnchor.x) / 0.5 * marginW + return min(max(x, -trailingMargin), leadingMargin) + } + private func edgeHeight(y: Double) -> Double { + let marginH = DeviceUtil.absWindowH * (scale - 1) / 2 + let topMargin = scaleAnchor.y / 0.5 * marginH + let bottomMargin = (1 - scaleAnchor.y) / 0.5 * marginH + return min(max(y, -bottomMargin), topMargin) + } + private func correctOffset() { + offset.width = edgeWidth(x: offset.width) + offset.height = edgeHeight(y: offset.height) + } + private func correctScaleAnchor(point: CGPoint) { + let x = min(1, max(0, point.x / DeviceUtil.absWindowW)) + let y = min(1, max(0, point.y / DeviceUtil.absWindowH)) + scaleAnchor = .init(x: x, y: y) + } + private func setOffset(_ offset: CGSize) { + self.offset = offset + correctOffset() + } + private func setScale(scale: Double, maximum: Double) { + guard scale >= 1 && scale <= maximum else { return } + self.scale = scale + correctOffset() + } + + func onSingleTapGestureEnded( + readingDirection: ReadingDirection, + setPageIndexOffsetAction: @escaping (Int) -> Void, + toggleShowsPanelAction: @escaping () -> Void + ) { + Logger.info("onSingleTapGestureEnded", context: ["readingDirection": readingDirection]) + guard readingDirection != .vertical, + let pointX = TouchHandler.shared.currentPoint?.x + else { + toggleShowsPanelAction() + return + } + let rightToLeft = readingDirection == .rightToLeft + if pointX < DeviceUtil.absWindowW * 0.2 { + setPageIndexOffsetAction(rightToLeft ? 1 : -1) + } else if pointX > DeviceUtil.absWindowW * (1 - 0.2) { + setPageIndexOffsetAction(rightToLeft ? -1 : 1) + } else { + toggleShowsPanelAction() + } + } + + func onDoubleTapGestureEnded(scaleMaximum: Double, doubleTapScale: Double) { + Logger.info("onDoubleTapGestureEnded", context: [ + "scaleMaximum": scaleMaximum, "doubleTapScale": doubleTapScale + ]) + let newScale = scale == 1 ? doubleTapScale : 1 + if let point = TouchHandler.shared.currentPoint { + correctScaleAnchor(point: point) + } + setOffset(.zero) + setScale(scale: newScale, maximum: scaleMaximum) + } + + func onMagnificationGestureChanged(value: Double, scaleMaximum: Double) { + Logger.info("onMagnificationGestureChanged", context: [ + "value": value, "scaleMaximum": scaleMaximum + ]) + if value == 1 { + baseScale = scale + } + if let point = TouchHandler.shared.currentPoint { + correctScaleAnchor(point: point) + } + setScale(scale: value * baseScale, maximum: scaleMaximum) + } + + func onMagnificationGestureEnded(value: Double, scaleMaximum: Double) { + Logger.info("onMagnificationGestureEnded", context: [ + "value": value, "scaleMaximum": scaleMaximum + ]) + onMagnificationGestureChanged(value: value, scaleMaximum: scaleMaximum) + if value * baseScale - 1 < 0.01 { + setScale(scale: 1, maximum: scaleMaximum) + } + baseScale = scale + } + + func onDragGestureChanged(value: DragGesture.Value) { + Logger.info("onDragGestureChanged", context: ["value": value]) + guard scale > 1 else { return } + let newX = value.translation.width + newOffset.width + let newY = value.translation.height + newOffset.height + let newOffsetW = edgeWidth(x: newX) + let newOffsetH = edgeHeight(y: newY) + setOffset(.init(width: newOffsetW, height: newOffsetH)) + } + + func onDragGestureEnded(value: DragGesture.Value) { + Logger.info("onDragGestureEnded", context: ["value": value]) + onDragGestureChanged(value: value) + if scale > 1 { + newOffset.width = offset.width + newOffset.height = offset.height + } + } + + func onControlPanelDismissGestureEnded(value: DragGesture.Value, dismissAction: @escaping () -> Void) { + Logger.info("onControlPanelDismissGestureEnded", context: ["value": value]) + if value.predictedEndTranslation.height > 30 { + dismissAction() + } + } +} diff --git a/EhPanda/View/Reading/Support/ImageStackView.swift b/EhPanda/View/Reading/Support/ImageStackView.swift deleted file mode 100644 index 83103406..00000000 --- a/EhPanda/View/Reading/Support/ImageStackView.swift +++ /dev/null @@ -1,355 +0,0 @@ -// -// ImageStackView.swift -// EhPanda -// - -import SwiftUI -import Kingfisher -import ComposableArchitecture - -// MARK: - Image Stack View -struct ImageStackView: View { - // MARK: - Properties - private let index: Int - private let store: StoreOf - @Binding private var setting: Setting - @ObservedObject private var viewModel: ReadingViewModel - @ObservedObject private var gestureCoordinator: GestureCoordinator - - // MARK: - Computed Properties - private var isDualPage: Bool { - setting.enablesDualPageMode && - setting.readingDirection != .vertical && - DeviceUtil.isLandscape - } - - private var backgroundColor: Color { - Color(.systemGray4) // This should match the main view's background - } - - private var imageStackConfig: ImageStackConfig { - let dualPageConfig = pageCoordinator.getDualPageConfiguration( - for: index, - setting: setting - ) - return ImageStackConfig(from: dualPageConfig) - } - - // MARK: - Dependencies - private var pageCoordinator: PageCoordinator { - // This would ideally be injected, but for now we create a temporary one - let coordinator = PageCoordinator() - coordinator.setup(pageCount: store.gallery.pageCount, setting: setting) - return coordinator - } - - // MARK: - Initialization - init( - index: Int, - store: StoreOf, - setting: Binding, - viewModel: ReadingViewModel, - gestureCoordinator: GestureCoordinator - ) { - self.index = index - self.store = store - _setting = setting - self.viewModel = viewModel - self.gestureCoordinator = gestureCoordinator - } - - // MARK: - Body - var body: some View { - HStack(spacing: 0) { - if imageStackConfig.isFirstAvailable { - ImageContainerView( - index: imageStackConfig.firstIndex, - store: store, - setting: $setting, - viewModel: viewModel, - isDualPage: isDualPage, - backgroundColor: backgroundColor - ) - } - - if imageStackConfig.isSecondAvailable { - ImageContainerView( - index: imageStackConfig.secondIndex, - store: store, - setting: $setting, - viewModel: viewModel, - isDualPage: isDualPage, - backgroundColor: backgroundColor - ) - } - } - } -} - -// MARK: - Image Container View -private struct ImageContainerView: View { - // MARK: - Properties - private let index: Int - private let store: StoreOf - @Binding private var setting: Setting - @ObservedObject private var viewModel: ReadingViewModel - private let isDualPage: Bool - private let backgroundColor: Color - - // MARK: - Computed Properties - private var imageURL: URL? { - store.imageURLs[index] - } - - private var originalImageURL: URL? { - store.originalImageURLs[index] - } - - private var loadingState: LoadingState { - store.imageURLLoadingStates[index] ?? .idle - } - - private var liveTextGroups: [LiveTextGroup] { - viewModel.liveTextGroups[index] ?? [] - } - - private var containerSize: CGSize { - let width = DeviceUtil.windowW / (isDualPage ? 2 : 1) - let height = width / Defaults.ImageSize.contentAspect - return CGSize(width: width, height: height) - } - - // MARK: - Initialization - init( - index: Int, - store: StoreOf, - setting: Binding, - viewModel: ReadingViewModel, - isDualPage: Bool, - backgroundColor: Color - ) { - self.index = index - self.store = store - _setting = setting - self.viewModel = viewModel - self.isDualPage = isDualPage - self.backgroundColor = backgroundColor - } - - // MARK: - Body - var body: some View { - Group { - if loadingState == .idle { - successView - } else { - loadingOrErrorView - } - } - .onAppear { - handleAppear() - } - .contextMenu { - contextMenuItems - } - } - - // MARK: - Success View - private var successView: some View { - ZStack { - imageView - .scaledToFit() - .overlay( - LiveTextView( - liveTextGroups: liveTextGroups, - focusedLiveTextGroup: viewModel.focusedLiveTextGroup, - tapAction: viewModel.setFocusedLiveTextGroup - ) - .opacity(viewModel.enablesLiveText ? 1 : 0) - ) - } - } - - // MARK: - Image View - @ViewBuilder - private var imageView: some View { - if let url = imageURL { - if url.isGIF { - KFAnimatedImage(url) - .placeholder { placeholderView() } - .fade(duration: 0.25) - .onSuccess { _ in handleImageSuccess() } - .onFailure { _ in handleImageFailure() } - } else { - KFImage(url) - .placeholder { placeholderView() } - .defaultModifier(withRoundedCorners: false) - .onSuccess { _ in handleImageSuccess() } - .onFailure { _ in handleImageFailure() } - } - } else { - placeholderView(Progress()) - } - } - - // MARK: - Placeholder View - private func placeholderView(_ progress: Progress = Progress()) -> some View { - Placeholder( - style: .progress( - pageNumber: index, - progress: progress, - isDualPage: isDualPage, - backgroundColor: backgroundColor - ) - ) - .frame(width: containerSize.width, height: containerSize.height) - } - - // MARK: - Loading/Error View - private var loadingOrErrorView: some View { - ZStack { - backgroundColor - - VStack(spacing: 30) { - Text("\(index)") - .font(.largeTitle.bold()) - .foregroundColor(.gray) - - ZStack { - if loadingState == .loading { - ProgressView() - } else { - Button(action: handleReloadTap) { - Image(systemSymbol: .exclamationmarkArrowTriangle2Circlepath) - .font(.system(size: 30, weight: .medium)) - .foregroundColor(.gray) - } - } - } - } - } - .frame(width: containerSize.width, height: containerSize.height) - } - - // MARK: - Context Menu - @ViewBuilder - private var contextMenuItems: some View { - Button(action: handleRefetch) { - Label( - L10n.Localizable.ReadingView.ContextMenu.Button.reload, - systemSymbol: .arrowCounterclockwise - ) - } - - if let imageURL = imageURL { - Button { - handleCopyImage(imageURL) - } label: { - Label( - L10n.Localizable.ReadingView.ContextMenu.Button.copy, - systemSymbol: .plusSquareOnSquare - ) - } - - Button { - handleSaveImage(imageURL) - } label: { - Label( - L10n.Localizable.ReadingView.ContextMenu.Button.save, - systemSymbol: .squareAndArrowDown - ) - } - - if let originalImageURL = originalImageURL { - Button { - handleSaveImage(originalImageURL) - } label: { - Label( - L10n.Localizable.ReadingView.ContextMenu.Button.saveOriginal, - systemSymbol: .squareAndArrowDownOnSquare - ) - } - } - - Button { - handleShareImage(imageURL) - } label: { - Label( - L10n.Localizable.ReadingView.ContextMenu.Button.share, - systemSymbol: .squareAndArrowUp - ) - } - } - } - - // MARK: - Event Handlers - private func handleAppear() { - let isDatabaseLoading = store.databaseLoadingState != .idle - - if !isDatabaseLoading { - if imageURL == nil { - store.send(.fetchImageURLs(index)) - } - store.send(.prefetchImages(index, setting.prefetchLimit)) - } - } - - private func handleImageSuccess() { - store.send(.onWebImageSucceeded(index)) - - if viewModel.enablesLiveText { - viewModel.analyzeImageForLiveText( - index: index, - imageURL: imageURL, - recognitionLanguages: store.galleryDetail?.language.codes - ) - } - } - - private func handleImageFailure() { - store.send(.onWebImageFailed(index)) - } - - private func handleReloadTap() { - if case .failed(let error) = loadingState { - if case .webImageFailed = error { - store.send(.onWebImageRetry(index)) - } else { - store.send(.refetchImageURLs(index)) - } - } - } - - private func handleRefetch() { - store.send(.refetchImageURLs(index)) - } - - private func handleCopyImage(_ url: URL) { - store.send(.copyImage(url)) - } - - private func handleSaveImage(_ url: URL) { - store.send(.saveImage(url)) - } - - private func handleShareImage(_ url: URL) { - store.send(.shareImage(url)) - } -} - -// MARK: - Preview -struct ImageStackView_Previews: PreviewProvider { - static var previews: some View { - ImageStackView( - index: 1, - store: .init( - initialState: .init(gallery: .empty), - reducer: ReadingReducer.init - ), - setting: .constant(.init()), - viewModel: ReadingViewModel(), - gestureCoordinator: GestureCoordinator() - ) - .previewLayout(.sizeThatFits) - .padding() - } -} diff --git a/EhPanda/View/Reading/Support/LiveTextHandler.swift b/EhPanda/View/Reading/Support/LiveTextHandler.swift new file mode 100644 index 00000000..28266c85 --- /dev/null +++ b/EhPanda/View/Reading/Support/LiveTextHandler.swift @@ -0,0 +1,189 @@ +// +// LiveTextHandler.swift +// EhPanda +// +// swiftlint:disable line_length +// Refercence +// https://www.codeproject.com/Articles/15573/2D-Polygon-Collision-Detection +// https://developer.apple.com/documentation/vision/recognizing_text_in_images +// https://github.com/TelegramMessenger/Telegram-iOS/blob/2a32c871882c4e1b1ccdecd34fccd301723b30d9/submodules/Translate/Sources/Translate.swift +// https://github.com/TelegramMessenger/Telegram-iOS/blob/0be460b147321b7455247aedca81ca819702959d/submodules/ImageContentAnalysis/Sources/ImageContentAnalysis.swift +// swiftlint:enable line_length +// + +import Vision +import SwiftUI +import Foundation + +final class LiveTextHandler: ObservableObject { + @Published var enablesLiveText = false + @Published var liveTextGroups = [Int: [LiveTextGroup]]() + @Published private(set) var focusedLiveTextGroup: LiveTextGroup? + + private var processingRequests = [VNRequest]() + + deinit { + cancelRequests() + } + + func cancelRequests() { + Logger.info("cancelRequests", context: [ + "processingRequestsCount": processingRequests.count + ]) + processingRequests.forEach { request in + request.cancel() + } + } + + func setFocusedLiveTextGroup(_ group: LiveTextGroup) { + Logger.info("setFocusedLiveTextGroup", context: ["group": group]) + focusedLiveTextGroup = group + } + + func analyzeImage(_ cgImage: CGImage, size: CGSize, index: Int, recognitionLanguages: [String]?) { + Logger.info("analyzeImage", context: [ + "index": index, "recognitionLanguages": recognitionLanguages as Any + ]) + + let requestHandler = VNImageRequestHandler(cgImage: cgImage) + let textRecognitionRequest = VNRecognizeTextRequest { [weak self] in + self?.textRecognitionHandler(request: $0, error: $1, size: size, index: index) + } + textRecognitionRequest.usesLanguageCorrection = true + textRecognitionRequest.preferBackgroundProcessing = true + if let languages = recognitionLanguages { + textRecognitionRequest.recognitionLanguages = languages + } + + processingRequests.append(textRecognitionRequest) + DispatchQueue.global(qos: .utility).async { [weak self] in + guard let self = self else { return } + do { + try requestHandler.perform([textRecognitionRequest]) + } catch { + self.removeRequest(textRecognitionRequest) + Logger.info("Unable to perform the requests.", context: ["error": error]) + } + } + } + + private func removeRequest(_ request: VNRequest) { + if let index = processingRequests.firstIndex(of: request) { + processingRequests.remove(at: index) + } + } + + private func textRecognitionHandler(request: VNRequest, error: Error?, size: CGSize, index: Int) { + Logger.info("textRecognitionHandler", context: [ + "request": request, "error": error as Any, "index": index + ]) + removeRequest(request) + + guard let observations = request.results as? [VNRecognizedTextObservation] else { return } + + DispatchQueue.global(qos: .userInteractive).async { [weak self] in + guard let self = self else { return } + let blocks: [LiveTextBlock] = observations.compactMap { observation in + guard let recognizedText = observation.topCandidates(1).first?.string else { return nil } + return .init( + text: recognizedText, + bounds: .init( + topLeft: observation.topLeft.verticalReversed, + topRight: observation.topRight.verticalReversed, + bottomLeft: observation.bottomLeft.verticalReversed, + bottomRight: observation.bottomRight.verticalReversed + ) + ) + } + + var groupData = [[LiveTextBlock]]() + blocks.forEach { newItem in + if let groupIndex = groupData.firstIndex(where: { items in + items.first { item in + let angle = abs(item.bounds.getAngle(size) - newItem.bounds.getAngle(size)) + .truncatingRemainder(dividingBy: 360.0) + let isAngleValid = angle < 5 || angle > (360 - 5) + let aHeight = item.bounds.getHeight(size) + let bHeight = newItem.bounds.getHeight(size) + let isHeightValid = abs(aHeight - bHeight) < (min(aHeight, bHeight) / 2) + + guard isAngleValid && isHeightValid else { return false } + return self.polygonsIntersecting( + lhs: item.bounds.expandingHalfHeight(size).edges, + rhs: newItem.bounds.expandingHalfHeight(size).edges + ) + } != nil + }) { + groupData[groupIndex].append(newItem) + } else { + groupData.append([newItem]) + } + } + + let groups = groupData.compactMap(LiveTextGroup.init) + DispatchQueue.main.async { + self.liveTextGroups[index] = groups + } + } + } + + private func polygonsIntersecting(lhs: [CGPoint], rhs: [CGPoint]) -> Bool { + guard !lhs.isEmpty, !rhs.isEmpty, lhs.count == rhs.count else { return false } + for points in [lhs, rhs] { + for index1 in 0..() - - // MARK: - Configuration - private var pageConfig: PageConfiguration = .init() - - // MARK: - Initialization - init() { - setupObservers() - } - - deinit { - cleanup() - } - - // MARK: - Setup Methods - func setup(pageCount: Int, setting: Setting) { - self.pageCount = pageCount - self.setting = setting - self.pageConfig = PageConfiguration(setting: setting) - - Logger.info("Page coordinator setup", context: [ - "pageCount": pageCount, - "readingDirection": setting.readingDirection.rawValue - ]) - } - - func setup(pageCount: Int, setting: Setting, initialPage: Int) { - setup(pageCount: pageCount, setting: setting) - - // Initialize slider value with reading progress - let validProgress = max(1, min(initialPage, pageCount)) - sliderValue = Float(validProgress) - - Logger.info("Page coordinator setup with initial page", context: [ - "initialPage": initialPage, - "validProgress": validProgress - ]) - } - - private func setupObservers() { - // Observe slider value changes for page navigation - $sliderValue - .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) - .sink { [weak self] newValue in - self?.handleSliderValueChange(newValue) - } - .store(in: &cancellables) - } - - func cleanup() { - cancellables.removeAll() - } - - // MARK: - Page Mapping Methods - - /// Maps from pager index to page number - func mapFromPager( - index: Int, - pageCount: Int, - setting: Setting, - isLandscape: Bool = DeviceUtil.isLandscape - ) -> Int { - Logger.info("Map from pager", context: [ - "index": index, - "pageCount": pageCount, - "isDualPage": isDualPageMode(setting: setting, isLandscape: isLandscape) - ]) - - guard isDualPageMode(setting: setting, isLandscape: isLandscape) else { - return index + 1 - } - - guard index > 0 else { return 1 } - - let result = setting.exceptCover ? index * 2 : index * 2 + 1 - - // Handle edge case for last page in dual mode - if result + 1 == pageCount { - return pageCount - } else { - return result - } - } - - /// Maps from page number to pager index - func mapToPager( - index: Int, - setting: Setting, - isLandscape: Bool = DeviceUtil.isLandscape - ) -> Int { - Logger.info("Map to pager", context: [ - "index": index, - "isDualPage": isDualPageMode(setting: setting, isLandscape: isLandscape) - ]) - - guard isDualPageMode(setting: setting, isLandscape: isLandscape) else { - return index - 1 - } - - guard index > 1 else { return 0 } - - return setting.exceptCover ? index / 2 : (index - 1) / 2 - } - - // MARK: - Page Navigation - - /// Updates the current page and synchronizes slider - func updateCurrentPage(_ pageIndex: Int) { - let clampedIndex = max(1, min(pageIndex, pageCount)) - sliderValue = Float(clampedIndex) - - Logger.info("Updated current page", context: [ - "pageIndex": pageIndex, - "clampedIndex": clampedIndex - ]) - } - - /// Handles page navigation with bounds checking - func navigatePage(offset: Int, currentIndex: Int) -> Int { - let newIndex = currentIndex + offset - let clampedIndex = max(0, min(newIndex, pageCount - 1)) - - Logger.info("Navigate page", context: [ - "offset": offset, - "currentIndex": currentIndex, - "newIndex": newIndex, - "clampedIndex": clampedIndex - ]) - - return clampedIndex - } - - /// Gets valid page range for the current configuration - func getValidPageRange() -> ClosedRange { - return 1...pageCount - } - - /// Checks if a page index is valid - func isValidPageIndex(_ index: Int) -> Bool { - return index >= 1 && index <= pageCount - } - - // MARK: - Dual Page Support - - /// Determines if dual page mode should be active - func isDualPageMode(setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> Bool { - return isLandscape && - setting.enablesDualPageMode && - setting.readingDirection != .vertical - } - - /// Gets the page configuration for dual page mode - func getDualPageConfiguration( - for index: Int, - setting: Setting, - isLandscape: Bool = DeviceUtil.isLandscape - ) -> DualPageConfiguration { - let isDualPage = isDualPageMode(setting: setting, isLandscape: isLandscape) - let isReversed = setting.readingDirection == .rightToLeft - let isFirstSingle = setting.exceptCover - let isFirstPageAndSingle = index == 1 && isFirstSingle - - let firstIndex = isDualPage && isReversed && !isFirstPageAndSingle ? index + 1 : index - let secondIndex = firstIndex + (isReversed ? -1 : 1) - - let isValidFirstRange = firstIndex >= 1 && firstIndex <= pageCount - let isValidSecondRange = isFirstSingle - ? secondIndex >= 2 && secondIndex <= pageCount - : secondIndex >= 1 && secondIndex <= pageCount - - return DualPageConfiguration( - firstIndex: firstIndex, - secondIndex: secondIndex, - isFirstAvailable: isValidFirstRange, - isSecondAvailable: !isFirstPageAndSingle && isValidSecondRange && isDualPage, - isDualPage: isDualPage - ) - } - - // MARK: - Auto Play Support - - /// Gets the next page index for auto play - func getNextAutoPlayIndex(currentIndex: Int) -> Int? { - let nextIndex = currentIndex + 1 - guard nextIndex < pageCount else { return nil } - return nextIndex - } - - // MARK: - Private Methods - - private func handleSliderValueChange(_ newValue: Float) { - Logger.debug("Handle slider value change", context: [ - "newValue": newValue, - "pageCount": pageCount - ]) - - // Validate slider value - let clampedValue = max(1, min(newValue, Float(pageCount))) - if clampedValue != newValue { - DispatchQueue.main.async { [weak self] in - self?.sliderValue = clampedValue - } - } - } -} - -// MARK: - Supporting Types - -/// Configuration for dual page display -struct DualPageConfiguration { - let firstIndex: Int - let secondIndex: Int - let isFirstAvailable: Bool - let isSecondAvailable: Bool - let isDualPage: Bool -} - -/// Configuration for page behavior -private struct PageConfiguration { - let enablesDualPage: Bool - let exceptCover: Bool - let readingDirection: ReadingDirection - - init(setting: Setting? = nil) { - self.enablesDualPage = setting?.enablesDualPageMode ?? false - self.exceptCover = setting?.exceptCover ?? false - self.readingDirection = setting?.readingDirection ?? .leftToRight - } -} - -// MARK: - Page Coordinator Extensions - -extension PageCoordinator { - /// Gets container data source for the current page configuration - func getContainerDataSource( - pageCount: Int, - setting: Setting, - isLandscape: Bool = DeviceUtil.isLandscape - ) -> [Int] { - let defaultData = Array(1...pageCount) - - guard isDualPageMode(setting: setting, isLandscape: isLandscape) else { - return defaultData - } - - let data = setting.exceptCover - ? [1] + Array(stride(from: 2, through: pageCount, by: 2)) - : Array(stride(from: 1, through: pageCount, by: 2)) - - Logger.info("Generated container data source", context: [ - "defaultCount": defaultData.count, - "dualPageCount": data.count, - "exceptCover": setting.exceptCover - ]) - - return data - } -} - -// MARK: - Image Stack Configuration - -/// Configuration for image stack display -struct ImageStackConfig { - let firstIndex: Int - let secondIndex: Int - let isFirstAvailable: Bool - let isSecondAvailable: Bool - - init(from dualPageConfig: DualPageConfiguration) { - self.firstIndex = dualPageConfig.firstIndex - self.secondIndex = dualPageConfig.secondIndex - self.isFirstAvailable = dualPageConfig.isFirstAvailable - self.isSecondAvailable = dualPageConfig.isSecondAvailable - } -} diff --git a/EhPanda/View/Reading/Support/PageHandler.swift b/EhPanda/View/Reading/Support/PageHandler.swift new file mode 100644 index 00000000..2a06f489 --- /dev/null +++ b/EhPanda/View/Reading/Support/PageHandler.swift @@ -0,0 +1,38 @@ +// +// PageHandler.swift +// EhPanda +// + +import SwiftUI + +final class PageHandler: ObservableObject { + @Published var sliderValue: Float = 1 { + didSet { + Logger.info("sliderValue.didSet", context: ["sliderValue": sliderValue]) + } + } + + func mapFromPager(index: Int, pageCount: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> Int { + guard isLandscape && setting.enablesDualPageMode + && setting.readingDirection != .vertical + else { return index + 1 } + guard index > 0 else { return 1 } + + let result = setting.exceptCover ? index * 2 : index * 2 + 1 + + if result + 1 == pageCount { + return pageCount + } else { + return result + } + } + + func mapToPager(index: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> Int { + guard isLandscape && setting.enablesDualPageMode + && setting.readingDirection != .vertical + else { return index - 1 } + guard index > 1 else { return 0 } + + return setting.exceptCover ? index / 2 : (index - 1) / 2 + } +} diff --git a/EhPanda/View/Reading/Support/ReadingViewExtensions.swift b/EhPanda/View/Reading/Support/ReadingViewExtensions.swift deleted file mode 100644 index 55905350..00000000 --- a/EhPanda/View/Reading/Support/ReadingViewExtensions.swift +++ /dev/null @@ -1,444 +0,0 @@ -// -// ReadingViewExtensions.swift -// EhPanda -// - -import SwiftUI -import SwiftUIPager -import ComposableArchitecture - -// MARK: - Auto Play Policy -enum AutoPlayPolicy: Int, CaseIterable, Identifiable { - var id: Int { rawValue } - - case off = -1 - case sec1 = 1 - case sec2 = 2 - case sec3 = 3 - case sec4 = 4 - case sec5 = 5 -} - -extension AutoPlayPolicy { - /// Human-readable value for the auto play policy - var value: String { - switch self { - case .off: - return L10n.Localizable.Enum.AutoPlayPolicy.Value.off - default: - return L10n.Localizable.Common.Value.seconds("\(rawValue)") - } - } - - /// Time interval for the timer (0 means disabled) - var timeInterval: TimeInterval { - return rawValue > 0 ? TimeInterval(rawValue) : 0 - } - - /// Whether auto play is enabled - var isEnabled: Bool { - return self != .off - } -} - -// MARK: - Reading View Modifiers - -extension View { - /// Applies all reading view modifiers including sheets, progress HUD, and animations - func readingViewModifiers( - store: StoreOf, - setting: Binding, - blurRadius: Double - ) -> some View { - self - .readingSheets(store: store, setting: setting, blurRadius: blurRadius) - .readingProgressHUD(store: store) - .readingAnimations() - .readingStatusBar(store: store) - } - - /// Applies reading-specific sheet presentations - private func readingSheets( - store: StoreOf, - setting: Binding, - blurRadius: Double - ) -> some View { - self - .sheet(item: Binding( - get: { store.route?.readingSetting }, - set: { _ in store.send(.setNavigation(nil)) } - )) { _ in - NavigationView { - ReadingSettingView( - readingDirection: setting.readingDirection, - prefetchLimit: setting.prefetchLimit, - enablesLandscape: setting.enablesLandscape, - contentDividerHeight: setting.contentDividerHeight, - maximumScaleFactor: setting.maximumScaleFactor, - doubleTapScaleFactor: setting.doubleTapScaleFactor - ) - .readingSettingToolbar { - store.send(.setNavigation(nil)) - } - } - .accentColor(setting.wrappedValue.accentColor) - .tint(setting.wrappedValue.accentColor) - .autoBlur(radius: blurRadius) - .navigationViewStyle(.stack) - } - .sheet(item: Binding( - get: { store.route?.share }, - set: { _ in store.send(.setNavigation(nil)) } - )) { shareItemBox in - ActivityView(activityItems: [shareItemBox.wrappedValue.associatedValue]) - .accentColor(setting.wrappedValue.accentColor) - .autoBlur(radius: blurRadius) - } - } - - /// Applies progress HUD for reading operations - private func readingProgressHUD(store: StoreOf) -> some View { - self.progressHUD( - config: store.hudConfig, - unwrapping: Binding( - get: { store.route }, - set: { store.send(.setNavigation($0)) } - ), - case: \.hud - ) - } - - /// Applies reading-specific animations - private func readingAnimations() -> some View { - self - .animation(.linear(duration: 0.1), value: UUID()) // Placeholder for gesture animations - .animation(.default, value: UUID()) // Placeholder for other animations - } - - /// Configures status bar visibility - private func readingStatusBar(store: StoreOf) -> some View { - self.statusBar(hidden: !store.showsPanel) - } -} - -// MARK: - Reading Setting Toolbar - -extension View { - func readingSettingToolbar(dismissAction: @escaping () -> Void) -> some View { - self.toolbar { - if !DeviceUtil.isPad && DeviceUtil.isLandscape { - CustomToolbarItem(placement: .cancellationAction) { - Button(action: dismissAction) { - Image(systemSymbol: .chevronDown) - } - } - } - } - } -} - -// MARK: - Reading Changes Observer - -extension View { - /// Observes reading-related changes and handles side effects - func observeReadingChanges( - store: StoreOf, - setting: Binding, - viewModel: ReadingViewModel, - pageCoordinator: PageCoordinator, - page: Page - ) -> some View { - self - .onChange(of: page.index) { _, newValue in - handlePageIndexChange( - newValue: newValue, - store: store, - setting: setting.wrappedValue, - pageCoordinator: pageCoordinator - ) - } - .onChange(of: pageCoordinator.sliderValue) { _, newValue in - handleSliderValueChange( - newValue: newValue, - store: store, - showsSliderPreview: store.showsSliderPreview, - page: page, - pageCoordinator: pageCoordinator, - setting: setting.wrappedValue - ) - } - .onChange(of: store.showsSliderPreview) { _, newValue in - handleSliderPreviewChange( - newValue: newValue, - pageCoordinator: pageCoordinator, - viewModel: viewModel, - page: page, - setting: setting.wrappedValue - ) - } - .onChange(of: store.readingProgress) { _, newValue in - handleReadingProgressChange( - newValue: newValue, - pageCoordinator: pageCoordinator, - page: page, - setting: setting.wrappedValue - ) - } - .onChange(of: store.route) { _, newValue in - handleRouteChange(newValue: newValue, viewModel: viewModel) - } - .onChange(of: viewModel.enablesLiveText) { _, newValue in - handleLiveTextToggle( - newValue: newValue, - store: store, - viewModel: viewModel - ) - } - .onChange(of: store.webImageLoadSuccessIndices) { _, newValue in - handleImageLoadSuccess( - newValue: newValue, - viewModel: viewModel, - store: store - ) - } - .onChange(of: setting.wrappedValue.enablesLandscape) { _, newValue in - handleLandscapeSettingChange(newValue: newValue, store: store) - } - } - - private func handlePageIndexChange( - newValue: Int, - store: StoreOf, - setting: Setting, - pageCoordinator: PageCoordinator - ) { - Logger.info("Page index changed", context: ["pageIndex": newValue]) - - let mappedValue = pageCoordinator.mapFromPager( - index: newValue, - pageCount: store.gallery.pageCount, - setting: setting - ) - - pageCoordinator.sliderValue = Float(mappedValue) - - if store.databaseLoadingState == .idle { - store.send(.syncReadingProgress(mappedValue)) - } - } - - // swiftlint:disable:next function_parameter_count - private func handleSliderValueChange( - newValue: Float, - store: StoreOf, - showsSliderPreview: Bool, - page: Page, - pageCoordinator: PageCoordinator, - setting: Setting - ) { - Logger.debug("Slider value changed", context: ["sliderValue": newValue]) - - if !showsSliderPreview { - let pagerIndex = pageCoordinator.mapToPager(index: Int(newValue), setting: setting) - if page.index != pagerIndex { - page.update(.new(index: pagerIndex)) - Logger.info("Pager updated from slider", context: ["pagerIndex": pagerIndex]) - } - } - } - - private func handleSliderPreviewChange( - newValue: Bool, - pageCoordinator: PageCoordinator, - viewModel: ReadingViewModel, - page: Page, - setting: Setting - ) { - Logger.info("Slider preview changed", context: ["isShown": newValue]) - - if !newValue { - let pagerIndex = pageCoordinator.mapToPager( - index: Int(pageCoordinator.sliderValue), - setting: setting - ) - if page.index != pagerIndex { - page.update(.new(index: pagerIndex)) - } - } - - viewModel.stopAutoPlay() - } - - private func handleReadingProgressChange( - newValue: Int, - pageCoordinator: PageCoordinator, - page: Page, - setting: Setting - ) { - Logger.info("Reading progress changed", context: ["readingProgress": newValue]) - - // Ensure valid reading progress (at least page 1) - let validProgress = max(1, newValue) - - // Update slider value - pageCoordinator.sliderValue = Float(validProgress) - - // Update pager position to match the reading progress - let pagerIndex = pageCoordinator.mapToPager(index: validProgress, setting: setting) - if page.index != pagerIndex { - page.update(.new(index: pagerIndex)) - Logger.info("Pager updated from reading progress", context: [ - "readingProgress": validProgress, - "pagerIndex": pagerIndex - ]) - } - } - - private func handleRouteChange(newValue: ReadingReducer.Route?, viewModel: ReadingViewModel) { - Logger.info("Route changed", context: ["route": newValue as Any]) - - if let route = newValue, ![ReadingReducer.Route.hud, nil].contains(where: { $0 == route }) { - viewModel.stopAutoPlay() - } - } - - private func handleLiveTextToggle( - newValue: Bool, - store: StoreOf, - viewModel: ReadingViewModel - ) { - Logger.info("Live text toggled", context: ["isEnabled": newValue]) - - if newValue { - store.webImageLoadSuccessIndices.forEach { index in - viewModel.analyzeImageForLiveText( - index: index, - imageURL: store.imageURLs[index], - recognitionLanguages: store.galleryDetail?.language.codes - ) - } - } - } - - private func handleImageLoadSuccess( - newValue: Set, - viewModel: ReadingViewModel, - store: StoreOf - ) { - Logger.info("Image load success indices changed", context: [ - "count": newValue.count - ]) - - if viewModel.enablesLiveText { - newValue.forEach { index in - viewModel.analyzeImageForLiveText( - index: index, - imageURL: store.imageURLs[index], - recognitionLanguages: store.galleryDetail?.language.codes - ) - } - } - } - - private func handleLandscapeSettingChange(newValue: Bool, store: StoreOf) { - Logger.info("Landscape setting changed", context: ["newValue": newValue]) - store.send(.setOrientationPortrait(!newValue)) - } -} - -// MARK: - Reading Control Panel - -/// Replacement for the original ControlPanel component with improved architecture -struct ReadingControlPanel: View { - @Binding private var showsPanel: Bool - @Binding private var showsSliderPreview: Bool - @Binding private var sliderValue: Float - @Binding private var setting: Setting - @Binding private var enablesLiveText: Bool - @Binding private var autoPlayPolicy: AutoPlayPolicy - - private let range: ClosedRange - private let previewURLs: [Int: URL] - private let dismissGesture: G - private let dismissAction: () -> Void - private let navigateSettingAction: () -> Void - private let reloadAllImagesAction: () -> Void - private let retryAllFailedImagesAction: () -> Void - private let fetchPreviewURLsAction: (Int) -> Void - - init( - showsPanel: Binding, - showsSliderPreview: Binding, - sliderValue: Binding, - setting: Binding, - enablesLiveText: Binding, - autoPlayPolicy: Binding, - range: ClosedRange, - previewURLs: [Int: URL], - dismissGesture: G, - dismissAction: @escaping () -> Void, - navigateSettingAction: @escaping () -> Void, - reloadAllImagesAction: @escaping () -> Void, - retryAllFailedImagesAction: @escaping () -> Void, - fetchPreviewURLsAction: @escaping (Int) -> Void - ) { - _showsPanel = showsPanel - _showsSliderPreview = showsSliderPreview - _sliderValue = sliderValue - _setting = setting - _enablesLiveText = enablesLiveText - _autoPlayPolicy = autoPlayPolicy - self.range = range - self.previewURLs = previewURLs - self.dismissGesture = dismissGesture - self.dismissAction = dismissAction - self.navigateSettingAction = navigateSettingAction - self.reloadAllImagesAction = reloadAllImagesAction - self.retryAllFailedImagesAction = retryAllFailedImagesAction - self.fetchPreviewURLsAction = fetchPreviewURLsAction - } - - var body: some View { - ControlPanel( - showsPanel: $showsPanel, - showsSliderPreview: $showsSliderPreview, - sliderValue: $sliderValue, - setting: $setting, - enablesLiveText: $enablesLiveText, - autoPlayPolicy: $autoPlayPolicy, - range: range, - previewURLs: previewURLs, - dismissGesture: dismissGesture, - dismissAction: dismissAction, - navigateSettingAction: navigateSettingAction, - reloadAllImagesAction: reloadAllImagesAction, - retryAllFailedImagesAction: retryAllFailedImagesAction, - fetchPreviewURLsAction: fetchPreviewURLsAction - ) - } -} - -// MARK: - Route Binding Extensions - -extension ReadingReducer.Route { - var readingSetting: EquatableVoid? { - if case .readingSetting(let void) = self { - return void - } - return nil - } - - var share: IdentifiableBox? { - if case .share(let shareItem) = self { - return shareItem - } - return nil - } - - var hud: Void? { - if case .hud = self { - return () - } - return nil - } -} diff --git a/EhPanda/View/Reading/Support/ReadingViewModel.swift b/EhPanda/View/Reading/Support/ReadingViewModel.swift deleted file mode 100644 index bc5c3e8b..00000000 --- a/EhPanda/View/Reading/Support/ReadingViewModel.swift +++ /dev/null @@ -1,319 +0,0 @@ -// -// ReadingViewModel.swift -// EhPanda -// - -import SwiftUI -import Combine -import Kingfisher - -// MARK: - Reading View Model -final class ReadingViewModel: ObservableObject { - // MARK: - Published Properties - @Published var enablesLiveText = false - @Published var liveTextGroups = [Int: [LiveTextGroup]]() - @Published var focusedLiveTextGroup: LiveTextGroup? - @Published var autoPlayPolicy: AutoPlayPolicy = .off - @Published var webImageLoadSuccessIndices = Set() - - // MARK: - Private Properties - private var autoPlayTimer: Timer? - private var liveTextRequests = [VNRequest]() - private var cancellables = Set() - - // MARK: - Initialization - init() { - setupObservers() - } - - deinit { - cleanup() - } - - // MARK: - Setup Methods - func setup(with state: ReadingReducer.State, setting: Setting) { - // Initialize with current state - webImageLoadSuccessIndices = state.webImageLoadSuccessIndices - - // Setup live text if needed - if enablesLiveText { - analyzeExistingImages(indices: Array(webImageLoadSuccessIndices)) - } - } - - private func setupObservers() { - // Observe live text state changes - $enablesLiveText - .sink { [weak self] isEnabled in - if isEnabled { - self?.analyzeExistingImages(indices: Array(self?.webImageLoadSuccessIndices ?? [])) - } else { - self?.clearLiveText() - } - } - .store(in: &cancellables) - } - - // MARK: - Auto Play Management - func setAutoPlayPolicy(_ policy: AutoPlayPolicy, pageUpdater: @escaping () -> Void) { - Logger.info("Setting auto play policy", context: ["policy": policy]) - - autoPlayPolicy = policy - autoPlayTimer?.invalidate() - - if policy.isEnabled { - autoPlayTimer = Timer.scheduledTimer(withTimeInterval: policy.timeInterval, repeats: true) { _ in - pageUpdater() - } - } - } - - func stopAutoPlay() { - autoPlayTimer?.invalidate() - autoPlayPolicy = .off - } - - // MARK: - Live Text Management - func setFocusedLiveTextGroup(_ group: LiveTextGroup) { - Logger.info("Setting focused live text group", context: ["group": group]) - focusedLiveTextGroup = group - } - - func analyzeImageForLiveText( - index: Int, - imageURL: URL?, - recognitionLanguages: [String]? - ) { - Logger.info("Analyzing image for live text", context: ["index": index]) - - guard enablesLiveText, - liveTextGroups[index] == nil, - let imageURL = imageURL, - let key = imageURL.absoluteString as String? - else { - Logger.info("Skipping live text analysis", context: [ - "enablesLiveText": enablesLiveText, - "alreadyAnalyzed": liveTextGroups[index] != nil, - "hasURL": imageURL != nil - ]) - return - } - - KingfisherManager.shared.cache.retrieveImage(forKey: key) { [weak self] result in - switch result { - case .success(let result): - if let image = result.image, let cgImage = image.cgImage { - self?.performLiveTextAnalysis( - cgImage: cgImage, - size: image.size, - index: index, - recognitionLanguages: recognitionLanguages - ) - } else { - Logger.info("Live text analysis: image not found", context: ["index": index]) - } - case .failure(let error): - Logger.info("Live text analysis failed", context: [ - "index": index, - "error": error - ] as [String: Any]) - } - } - } - - private func analyzeExistingImages(indices: [Int]) { - indices.forEach { _ in - // This would be called with proper parameters from the main view - // analyzeImageForLiveText(index: index, imageURL: nil, recognitionLanguages: nil) - } - } - - private func performLiveTextAnalysis( - cgImage: CGImage, - size: CGSize, - index: Int, - recognitionLanguages: [String]? - ) { - let requestHandler = VNImageRequestHandler(cgImage: cgImage) - let textRecognitionRequest = VNRecognizeTextRequest { [weak self] request, error in - self?.handleLiveTextRecognition( - request: request, - error: error, - size: size, - index: index - ) - } - - textRecognitionRequest.usesLanguageCorrection = true - textRecognitionRequest.preferBackgroundProcessing = true - - if let languages = recognitionLanguages { - textRecognitionRequest.recognitionLanguages = languages - } - - liveTextRequests.append(textRecognitionRequest) - - DispatchQueue.global(qos: .utility).async { [weak self] in - do { - try requestHandler.perform([textRecognitionRequest]) - } catch { - self?.removeLiveTextRequest(textRecognitionRequest) - Logger.info("Live text recognition failed", context: ["error": error]) - } - } - } - - private func handleLiveTextRecognition( - request: VNRequest, - error: Error?, - size: CGSize, - index: Int - ) { - removeLiveTextRequest(request) - - guard let observations = request.results as? [VNRecognizedTextObservation] else { - return - } - - DispatchQueue.global(qos: .userInteractive).async { [weak self] in - let blocks = self?.processLiveTextObservations(observations) ?? [] - let groups = self?.groupLiveTextBlocks(blocks, size: size) ?? [] - - DispatchQueue.main.async { - self?.liveTextGroups[index] = groups - } - } - } - - private func processLiveTextObservations(_ observations: [VNRecognizedTextObservation]) -> [LiveTextBlock] { - return observations.compactMap { observation in - guard let recognizedText = observation.topCandidates(1).first?.string else { - return nil - } - - return LiveTextBlock( - text: recognizedText, - bounds: LiveTextBounds( - topLeft: observation.topLeft.verticalReversed, - topRight: observation.topRight.verticalReversed, - bottomLeft: observation.bottomLeft.verticalReversed, - bottomRight: observation.bottomRight.verticalReversed - ) - ) - } - } - - private func groupLiveTextBlocks(_ blocks: [LiveTextBlock], size: CGSize) -> [LiveTextGroup] { - var groupData = [[LiveTextBlock]]() - - blocks.forEach { newBlock in - if let groupIndex = findMatchingGroup(for: newBlock, in: groupData, size: size) { - groupData[groupIndex].append(newBlock) - } else { - groupData.append([newBlock]) - } - } - - return groupData.compactMap(LiveTextGroup.init) - } - - private func findMatchingGroup( - for newBlock: LiveTextBlock, - in groupData: [[LiveTextBlock]], - size: CGSize - ) -> Int? { - return groupData.firstIndex { blocks in - blocks.contains { existingBlock in - areLiveTextBlocksCompatible(existingBlock, newBlock, size: size) - } - } - } - - private func areLiveTextBlocksCompatible( - _ block1: LiveTextBlock, - _ block2: LiveTextBlock, - size: CGSize - ) -> Bool { - let angle1 = block1.bounds.getAngle(size) - let angle2 = block2.bounds.getAngle(size) - let angleDiff = abs(angle1 - angle2).truncatingRemainder(dividingBy: 360.0) - let isAngleValid = angleDiff < 5 || angleDiff > (360 - 5) - - let height1 = block1.bounds.getHeight(size) - let height2 = block2.bounds.getHeight(size) - let isHeightValid = abs(height1 - height2) < (min(height1, height2) / 2) - - guard isAngleValid && isHeightValid else { return false } - - return arePolygonsIntersecting( - lhs: block1.bounds.expandingHalfHeight(size).edges, - rhs: block2.bounds.expandingHalfHeight(size).edges - ) - } - - private func arePolygonsIntersecting(lhs: [CGPoint], rhs: [CGPoint]) -> Bool { - guard !lhs.isEmpty, !rhs.isEmpty, lhs.count == rhs.count else { return false } - - for points in [lhs, rhs] { - for index1 in 0.. (min: Double, max: Double) { - let projections = points.map { point in - basis.x * point.x + basis.y * point.y - } - return (projections.min() ?? 0, projections.max() ?? 0) - } - - private func clearLiveText() { - liveTextGroups.removeAll() - focusedLiveTextGroup = nil - cancelLiveTextRequests() - } - - private func removeLiveTextRequest(_ request: VNRequest) { - if let index = liveTextRequests.firstIndex(of: request) { - liveTextRequests.remove(at: index) - } - } - - private func cancelLiveTextRequests() { - Logger.info("Canceling live text requests", context: [ - "count": liveTextRequests.count - ]) - liveTextRequests.forEach { $0.cancel() } - liveTextRequests.removeAll() - } - - // MARK: - Cleanup - func cleanup() { - autoPlayTimer?.invalidate() - cancelLiveTextRequests() - cancellables.removeAll() - } -} - -// MARK: - Extensions -private extension CGPoint { - var verticalReversed: CGPoint { - CGPoint(x: x, y: 1 - y) - } -} - -// MARK: - Import Vision Framework -import Vision From d11f6ee1d7381fd3de13676f1673923574e08844 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 22 Oct 2025 22:36:11 +0800 Subject: [PATCH 34/40] Update CI --- .github/workflows/deploy-pre-release.yml | 7 ++++++- .github/workflows/deploy.yml | 7 ++++++- .github/workflows/test.yml | 7 ++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-pre-release.yml b/.github/workflows/deploy-pre-release.yml index a0420bbf..571d459f 100644 --- a/.github/workflows/deploy-pre-release.yml +++ b/.github/workflows/deploy-pre-release.yml @@ -36,7 +36,12 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Install iOS 26 Platform - run: xcodebuild -downloadPlatform iOS + uses: nick-fields/retry@v3 + with: + retry_on: error + max_attempts: 10 + timeout_minutes: 999 + command: xcodebuild -downloadPlatform iOS - name: Show Xcode version run: xcodebuild -version - name: Run tests diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 84e90f99..37e75998 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,7 +32,12 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Install iOS 26 Platform - run: xcodebuild -downloadPlatform iOS + uses: nick-fields/retry@v3 + with: + retry_on: error + max_attempts: 10 + timeout_minutes: 999 + command: xcodebuild -downloadPlatform iOS - name: Show Xcode version run: xcodebuild -version - name: Run tests diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 532b4d60..e960930e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,12 @@ jobs: - name: Checkout uses: actions/checkout@v4 - name: Install iOS 26 Platform - run: xcodebuild -downloadPlatform iOS + uses: nick-fields/retry@v3 + with: + retry_on: error + max_attempts: 10 + timeout_minutes: 999 + command: xcodebuild -downloadPlatform iOS - name: Show Xcode version run: xcodebuild -version - name: Run tests From 183aed33413e211589df7ed18f86cc6b37afc528 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 22 Oct 2025 22:36:42 +0800 Subject: [PATCH 35/40] Update code-level contributors --- EhPanda/App/Generated/Strings.swift | 4 ++++ EhPanda/App/en.lproj/Constant.strings | 2 ++ EhPanda/View/Setting/Components/AboutView.swift | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 7202a961..0d798157 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -90,6 +90,8 @@ internal enum L10n { } internal enum CodeLevelContributor { internal enum Link { + /// https://github.com/aalberrty + internal static let aalberrty = L10n.tr("Constant", "app.code_level_contributor.link.aalberrty", fallback: "https://github.com/aalberrty") /// https://github.com/chihchy internal static let chihchy = L10n.tr("Constant", "app.code_level_contributor.link.chihchy", fallback: "https://github.com/chihchy") /// https://github.com/Jimmy-Prime @@ -100,6 +102,8 @@ internal enum L10n { internal static let xioxin = L10n.tr("Constant", "app.code_level_contributor.link.xioxin", fallback: "https://github.com/xioxin") } internal enum Text { + /// Zack Asahina + internal static let aalberrty = L10n.tr("Constant", "app.code_level_contributor.text.aalberrty", fallback: "Zack Asahina") /// Chihchy internal static let chihchy = L10n.tr("Constant", "app.code_level_contributor.text.chihchy", fallback: "Chihchy") /// Jimmy Prime diff --git a/EhPanda/App/en.lproj/Constant.strings b/EhPanda/App/en.lproj/Constant.strings index 29f37839..c08edd41 100644 --- a/EhPanda/App/en.lproj/Constant.strings +++ b/EhPanda/App/en.lproj/Constant.strings @@ -34,10 +34,12 @@ // Code level contributor "app.code_level_contributor.link.chihchy" = "https://github.com/chihchy"; +"app.code_level_contributor.link.aalberrty" = "https://github.com/aalberrty"; "app.code_level_contributor.link.Jimmy-Prime" = "https://github.com/Jimmy-Prime"; "app.code_level_contributor.link.xioxin" = "https://github.com/xioxin"; "app.code_level_contributor.link.vvbbnn00" = "https://github.com/vvbbnn00"; "app.code_level_contributor.text.chihchy" = "Chihchy"; +"app.code_level_contributor.text.aalberrty" = "Zack Asahina"; "app.code_level_contributor.text.Jimmy-Prime" = "Jimmy Prime"; "app.code_level_contributor.text.xioxin" = "xioxin"; "app.code_level_contributor.text.vvbbnn00" = "vvbbnn00"; diff --git a/EhPanda/View/Setting/Components/AboutView.swift b/EhPanda/View/Setting/Components/AboutView.swift index 7d3d0feb..9b6c91ec 100644 --- a/EhPanda/View/Setting/Components/AboutView.swift +++ b/EhPanda/View/Setting/Components/AboutView.swift @@ -107,6 +107,10 @@ struct AboutView: View { urlString: L10n.Constant.App.CodeLevelContributor.Link.chihchy, text: L10n.Constant.App.CodeLevelContributor.Text.chihchy ), + .init( + urlString: L10n.Constant.App.CodeLevelContributor.Link.aalberrty, + text: L10n.Constant.App.CodeLevelContributor.Text.aalberrty + ), .init( urlString: L10n.Constant.App.CodeLevelContributor.Link.jimmyPrime, text: L10n.Constant.App.CodeLevelContributor.Text.jimmyPrime From f375bc8297afddafe515a1c1f50de9243645dd69 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 22 Oct 2025 23:56:54 +0800 Subject: [PATCH 36/40] Apply more glass effect --- EhPanda/App/Generated/Strings.swift | 12 ----- EhPanda/App/de.lproj/Localizable.strings | 3 -- EhPanda/App/en.lproj/Localizable.strings | 3 -- EhPanda/App/ja.lproj/Localizable.strings | 3 -- EhPanda/App/ko.lproj/Localizable.strings | 3 -- EhPanda/App/zh-Hans.lproj/Localizable.strings | 3 -- .../App/zh-Hant-HK.lproj/Localizable.strings | 4 -- .../App/zh-Hant-TW.lproj/Localizable.strings | 3 -- EhPanda/App/zh-Hant.lproj/Localizable.strings | 3 -- .../View/Detail/Archives/ArchivesView.swift | 47 ++++++++++------- .../Detail/Components/PostCommentView.swift | 14 +++--- .../View/Search/Support/QuickSearchView.swift | 4 +- .../Components/LaboratorySettingView.swift | 25 ++++++---- EhPanda/View/Setting/Login/LoginView.swift | 50 ++++++++++++------- .../View/Support/Components/AlertView.swift | 7 ++- .../Support/Components/ToolbarItems.swift | 5 +- 16 files changed, 92 insertions(+), 97 deletions(-) diff --git a/EhPanda/App/Generated/Strings.swift b/EhPanda/App/Generated/Strings.swift index 0d798157..1af4a177 100644 --- a/EhPanda/App/Generated/Strings.swift +++ b/EhPanda/App/Generated/Strings.swift @@ -1965,12 +1965,6 @@ internal enum L10n { } } internal enum PostCommentView { - internal enum Button { - /// Cancel - internal static let cancel = L10n.tr("Localizable", "post_comment_view.button.cancel", fallback: "Cancel") - /// Post - internal static let post = L10n.tr("Localizable", "post_comment_view.button.post", fallback: "Post") - } internal enum Title { /// Edit comment internal static let editComment = L10n.tr("Localizable", "post_comment_view.title.edit_comment", fallback: "Edit comment") @@ -2001,12 +1995,6 @@ internal enum L10n { /// Quick search internal static let quickSearch = L10n.tr("Localizable", "quick_search_view.title.quick_search", fallback: "Quick search") } - internal enum ToolbarItem { - internal enum Button { - /// Confirm - internal static let confirm = L10n.tr("Localizable", "quick_search_view.toolbar_item.button.confirm", fallback: "Confirm") - } - } } internal enum ReadingSettingView { internal enum Section { diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index 43db93b8..b505d8d4 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -147,7 +147,6 @@ "quick_search_view.title.content" = "Content"; "quick_search_view.title.name" = "Name"; "quick_search_view.placeholder.optional" = "Optional"; -"quick_search_view.toolbar_item.button.confirm" = "Confirm"; // MARK: SettingView "setting_view.title.setting" = "Einstellungen"; @@ -338,8 +337,6 @@ // MARK: PostCommentView "post_comment_view.title.post_comment" = "Kommentar abgeben"; "post_comment_view.title.edit_comment" = "Kommentar bearbeiten"; -"post_comment_view.button.cancel" = "Abbrechen"; -"post_comment_view.button.post" = "Senden"; // MARK: PreviewsView "previews_view.title.previews" = "Vorschau"; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index bc56f1d1..a3ca9bac 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -147,7 +147,6 @@ "quick_search_view.title.content" = "Content"; "quick_search_view.title.name" = "Name"; "quick_search_view.placeholder.optional" = "Optional"; -"quick_search_view.toolbar_item.button.confirm" = "Confirm"; // MARK: SettingView "setting_view.title.setting" = "Setting"; @@ -338,8 +337,6 @@ // MARK: PostCommentView "post_comment_view.title.post_comment" = "Post comment"; "post_comment_view.title.edit_comment" = "Edit comment"; -"post_comment_view.button.cancel" = "Cancel"; -"post_comment_view.button.post" = "Post"; // MARK: PreviewsView "previews_view.title.previews" = "Previews"; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 362be102..fa68dba1 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -147,7 +147,6 @@ "quick_search_view.title.content" = "内容"; "quick_search_view.title.name" = "名前"; "quick_search_view.placeholder.optional" = "任意"; -"quick_search_view.toolbar_item.button.confirm" = "確認"; // MARK: SettingView "setting_view.title.setting" = "設定"; @@ -338,8 +337,6 @@ // MARK: PostCommentView "post_comment_view.title.post_comment" = "コメントを書く"; "post_comment_view.title.edit_comment" = "コメントを編集"; -"post_comment_view.button.cancel" = "キャンセル"; -"post_comment_view.button.post" = "投稿"; // MARK: PreviewsView "previews_view.title.previews" = "プレビュー"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index 5d652367..b9266898 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -147,7 +147,6 @@ "quick_search_view.title.content" = "Content"; "quick_search_view.title.name" = "Name"; "quick_search_view.placeholder.optional" = "Optional"; -"quick_search_view.toolbar_item.button.confirm" = "확인"; // MARK: SettingView "setting_view.title.setting" = "설정"; @@ -338,8 +337,6 @@ // MARK: PostCommentView "post_comment_view.title.post_comment" = "평가 남기기"; "post_comment_view.title.edit_comment" = "평가 수정"; -"post_comment_view.button.cancel" = "취소"; -"post_comment_view.button.post" = "등록"; // MARK: PreviewsView "previews_view.title.previews" = "미리보기"; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index b6ecccdf..19a4d6b0 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -147,7 +147,6 @@ "quick_search_view.title.content" = "内容"; "quick_search_view.title.name" = "名称"; "quick_search_view.placeholder.optional" = "可选"; -"quick_search_view.toolbar_item.button.confirm" = "确认"; // MARK: SettingView "setting_view.title.setting" = "设置"; @@ -338,8 +337,6 @@ // MARK: PostCommentView "post_comment_view.title.post_comment" = "发布评论"; "post_comment_view.title.edit_comment" = "编辑评论"; -"post_comment_view.button.cancel" = "取消"; -"post_comment_view.button.post" = "发布"; // MARK: PreviewsView "previews_view.title.previews" = "预览"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index c0d19029..948d5eb2 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -147,7 +147,6 @@ "quick_search_view.title.content" = "搜尋內容"; "quick_search_view.title.name" = "名稱"; "quick_search_view.placeholder.optional" = "(可選)"; -"quick_search_view.toolbar_item.button.confirm" = "確定"; // MARK: SettingView "setting_view.title.setting" = "設定"; @@ -338,9 +337,6 @@ // MARK: PostCommentView "post_comment_view.title.post_comment" = "發表留言"; "post_comment_view.title.edit_comment" = "編輯留言"; -"post_comment_view.button.cancel" = "取消"; -"post_comment_view.button.post" = "發表"; - // MARK: PreviewsView "previews_view.title.previews" = "預覽"; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index a035bada..0c4f150d 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -147,7 +147,6 @@ "quick_search_view.title.content" = "搜尋內容"; "quick_search_view.title.name" = "名稱"; "quick_search_view.placeholder.optional" = "(可選)"; -"quick_search_view.toolbar_item.button.confirm" = "確定"; // MARK: SettingView "setting_view.title.setting" = "設定"; @@ -338,8 +337,6 @@ // MARK: PostCommentView "post_comment_view.title.post_comment" = "發表留言"; "post_comment_view.title.edit_comment" = "編輯留言"; -"post_comment_view.button.cancel" = "取消"; -"post_comment_view.button.post" = "發表"; // MARK: PreviewsView "previews_view.title.previews" = "預覽"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index 67060d21..ad77e167 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -147,7 +147,6 @@ "quick_search_view.title.content" = "搜尋內容"; "quick_search_view.title.name" = "名稱"; "quick_search_view.placeholder.optional" = "(可選)"; -"quick_search_view.toolbar_item.button.confirm" = "確定"; // MARK: SettingView "setting_view.title.setting" = "設定"; @@ -338,8 +337,6 @@ // MARK: PostCommentView "post_comment_view.title.post_comment" = "發表留言"; "post_comment_view.title.edit_comment" = "編輯留言"; -"post_comment_view.button.cancel" = "取消"; -"post_comment_view.button.post" = "發表"; // MARK: PreviewsView "previews_view.title.previews" = "預覽"; diff --git a/EhPanda/View/Detail/Archives/ArchivesView.swift b/EhPanda/View/Detail/Archives/ArchivesView.swift index 81966f7b..828e681b 100644 --- a/EhPanda/View/Detail/Archives/ArchivesView.swift +++ b/EhPanda/View/Detail/Archives/ArchivesView.swift @@ -164,19 +164,28 @@ private struct HathArchiveGrid: View { var body: some View { VStack(spacing: 10) { - Text(archive.resolution.value).font(.title3.bold()) + Text(archive.resolution.value) + .font(.title3.bold()) + VStack { - Text(archive.fileSize).fontWeight(.medium).font(.caption) - Text(archive.price).foregroundColor(fileSizeColor).font(.caption2) + Text(archive.fileSize) + .fontWeight(.medium) + .font(.caption) + + Text(archive.price) + .foregroundColor(fileSizeColor) + .font(.caption2) } .lineLimit(1) } .foregroundColor(foregroundColor) .frame(width: width, height: height) - .contentShape(Rectangle()).overlay( + .contentShape(.rect) + .overlay( RoundedRectangle(cornerRadius: 10) .stroke(borderColor, lineWidth: 1) ) + .glassEffect(.clear.interactive(), in: .rect(cornerRadius: 10)) } } @@ -205,19 +214,23 @@ private struct DownloadButton: View { } var body: some View { - HStack { - Spacer() - Text(L10n.Localizable.ArchivesView.Button.downloadToHathClient) - .font(.headline).foregroundColor(textColor) - Spacer() - } - .frame(height: 50).background(backgroundColor) - .cornerRadius(30).padding(paddingInsets) - .onTapGesture { if !isDisabled { action() }} - .onLongPressGesture( - minimumDuration: 0, maximumDistance: 50, - pressing: { isPressing = $0 }, perform: {} - ) + Text(L10n.Localizable.ArchivesView.Button.downloadToHathClient) + .font(.headline) + .foregroundStyle(textColor) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(backgroundColor) + .animation(.default, value: backgroundColor) + .clipShape(.rect(cornerRadius: 30)) + .glassEffect(.regular.interactive()) + .padding(paddingInsets) + .onTapGesture(perform: { if !isDisabled { action() }}) + .onLongPressGesture( + minimumDuration: 0, + maximumDistance: 50, + pressing: { isPressing = $0 }, + perform: {} + ) } } diff --git a/EhPanda/View/Detail/Components/PostCommentView.swift b/EhPanda/View/Detail/Components/PostCommentView.swift index b298271b..f99e998d 100644 --- a/EhPanda/View/Detail/Components/PostCommentView.swift +++ b/EhPanda/View/Detail/Components/PostCommentView.swift @@ -34,19 +34,19 @@ struct PostCommentView: View { var body: some View { NavigationView { VStack { - TextEditor(text: $content).focused($isTextEditorFocused).padding() + TextEditor(text: $content) + .focused($isTextEditorFocused) + .padding() + Spacer() } .toolbar { ToolbarItem(placement: .cancellationAction) { - Button(L10n.Localizable.PostCommentView.Button.cancel, action: cancelAction) + Button(role: .close, action: cancelAction) } ToolbarItem(placement: .confirmationAction) { - Button( - L10n.Localizable.PostCommentView.Button.post, - action: postAction - ) - .disabled(content.isEmpty) + Button(role: .confirm, action: postAction) + .disabled(content.isEmpty) } } .navigationBarTitleDisplayMode(.inline) diff --git a/EhPanda/View/Search/Support/QuickSearchView.swift b/EhPanda/View/Search/Support/QuickSearchView.swift index c2ec2137..5c60c160 100644 --- a/EhPanda/View/Search/Support/QuickSearchView.swift +++ b/EhPanda/View/Search/Support/QuickSearchView.swift @@ -187,9 +187,7 @@ extension QuickSearchView { private func toolbar() -> some ToolbarContent { CustomToolbarItem { - Button(action: confirmAction) { - Text(L10n.Localizable.QuickSearchView.ToolbarItem.Button.confirm).bold() - } + Button(role: .confirm, action: confirmAction) } } } diff --git a/EhPanda/View/Setting/Components/LaboratorySettingView.swift b/EhPanda/View/Setting/Components/LaboratorySettingView.swift index ec0e4d81..15a75877 100644 --- a/EhPanda/View/Setting/Components/LaboratorySettingView.swift +++ b/EhPanda/View/Setting/Components/LaboratorySettingView.swift @@ -53,18 +53,23 @@ struct LaboratoryCell: View { var body: some View { HStack { - Spacer() - Group { - Image(systemSymbol: symbol) - Text(title).bold() - } - .foregroundColor(contentColor).font(.title2) - Spacer() + Image(systemSymbol: symbol) + + Text(title) + .bold() } - .contentShape(Rectangle()).onTapGesture { isOn.toggle() } - .minimumScaleFactor(0.75).padding(.vertical, 20) - .background(bgColor).cornerRadius(15).lineLimit(1) + .foregroundStyle(contentColor) + .font(.title2) + .frame(maxWidth: .infinity) + .contentShape(.rect) + .onTapGesture(perform: { isOn.toggle() }) + .minimumScaleFactor(0.75) + .padding(.vertical, 20) + .background(bgColor) + .cornerRadius(15) + .lineLimit(1) .animation(.default, value: isOn) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 15)) } } diff --git a/EhPanda/View/Setting/Login/LoginView.swift b/EhPanda/View/Setting/Login/LoginView.swift index 4b0c9e72..62e484cf 100644 --- a/EhPanda/View/Setting/Login/LoginView.swift +++ b/EhPanda/View/Setting/Login/LoginView.swift @@ -27,16 +27,22 @@ struct LoginView: View { WaveForm(color: Color(.systemGray2).opacity(0.2), amplify: 100, isReversed: true) WaveForm(color: Color(.systemGray).opacity(0.2), amplify: 120, isReversed: false) } - .offset(y: proxy.size.height * 0.3).drawingGroup() + .offset(y: proxy.size.height * 0.3) + .drawingGroup() + VStack(spacing: 15) { Group { LoginTextField( - focusedField: $focusedField, text: $store.username, - description: L10n.Localizable.LoginView.Title.username, isPassword: false + focusedField: $focusedField, + text: $store.username, + description: L10n.Localizable.LoginView.Title.username, + isPassword: false ) LoginTextField( - focusedField: $focusedField, text: $store.password, - description: L10n.Localizable.LoginView.Title.password, isPassword: true + focusedField: $focusedField, + text: $store.password, + description: L10n.Localizable.LoginView.Title.password, + isPassword: true ) } .padding(.horizontal, proxy.size.width * 0.2) @@ -44,17 +50,21 @@ struct LoginView: View { Button { store.send(.login) } label: { - Image(systemSymbol: .chevronForwardCircleFill) + Image(systemSymbol: .chevronForward) + .padding() + .clipShape(.circle) } .overlay { ProgressView() .tint(nil) .opacity(store.loginState == .loading ? 1 : 0) } - .imageScale(.large) - .font(.largeTitle) - .foregroundColor(store.loginButtonColor) - .disabled(store.loginButtonDisabled).padding(.top, 30) + .font(.title) + .foregroundStyle(store.loginButtonColor) + .disabled(store.loginButtonDisabled) + .glassEffect(.regular.interactive(), in: .circle) + .clipShape(.circle) + .padding(.top, 30) } } } @@ -100,10 +110,6 @@ private struct LoginTextField: View { private let description: String private let isPassword: Bool - private var backgroundColor: Color { - colorScheme == .light ? Color(.systemGray6) : Color(.systemGray5) - } - init( focusedField: FocusState.Binding, text: Binding, description: String, isPassword: Bool @@ -116,7 +122,10 @@ private struct LoginTextField: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Text(description).font(.caption).foregroundStyle(.secondary) + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + Group { if isPassword { SecureField("", text: $text) @@ -125,10 +134,13 @@ private struct LoginTextField: View { } } .focused(focusedField.projectedValue, equals: isPassword ? .password : .username) - .textContentType(isPassword ? .password : .username).submitLabel(isPassword ? .done : .next) - .textInputAutocapitalization(.none).disableAutocorrection(true) - .keyboardType(isPassword ? .asciiCapable : .default).padding(10) - .background(backgroundColor.opacity(0.75).cornerRadius(8)) + .textContentType(isPassword ? .password : .username) + .submitLabel(isPassword ? .done : .next) + .textInputAutocapitalization(.none) + .disableAutocorrection(true) + .keyboardType(isPassword ? .asciiCapable : .default) + .padding(10) + .glassEffect(.regular.tint(Color(.systemGray5)), in: .rect(cornerRadius: 8)) } } } diff --git a/EhPanda/View/Support/Components/AlertView.swift b/EhPanda/View/Support/Components/AlertView.swift index 3bb57300..06481449 100644 --- a/EhPanda/View/Support/Components/AlertView.swift +++ b/EhPanda/View/Support/Components/AlertView.swift @@ -121,9 +121,12 @@ struct AlertViewButton: View { var body: some View { Button(action: action) { - Text(title).foregroundColor(.primary.opacity(0.7)).textCase(.uppercase) + Text(title) + .foregroundColor(.primary.opacity(0.7)) + .textCase(.uppercase) } - .buttonStyle(.bordered).buttonBorderShape(.capsule) + .buttonBorderShape(.capsule) + .buttonStyle(.glass) } } diff --git a/EhPanda/View/Support/Components/ToolbarItems.swift b/EhPanda/View/Support/Components/ToolbarItems.swift index 7139d358..ef4d850e 100644 --- a/EhPanda/View/Support/Components/ToolbarItems.swift +++ b/EhPanda/View/Support/Components/ToolbarItems.swift @@ -23,10 +23,11 @@ struct CustomToolbarItem: ToolbarContent { var body: some ToolbarContent { ToolbarItem(placement: placement) { - HStack { + HStack(spacing: 14) { content } - .foregroundColor(tint).disabled(disabled) + .foregroundColor(tint) + .disabled(disabled) } } } From 48062f2d5b0a023bfca2990c73d924580ce0fec6 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Wed, 22 Oct 2025 23:57:18 +0800 Subject: [PATCH 37/40] Hide WebView safe area footer --- EhPanda/View/Setting/AccountSetting/AccountSettingView.swift | 1 + EhPanda/View/Setting/EhSetting/EhSettingView.swift | 1 + EhPanda/View/Setting/Login/LoginView.swift | 1 + 3 files changed, 3 insertions(+) diff --git a/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift b/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift index cce5ff1d..09b2d9b9 100644 --- a/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift +++ b/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift @@ -63,6 +63,7 @@ struct AccountSettingView: View { ) .sheet(item: $store.route.sending(\.setNavigation).webView, id: \.absoluteString) { url in WebView(url: url) + .ignoresSafeArea(edges: .bottom) .autoBlur(radius: blurRadius) } .onAppear { store.send(.loadCookies) } diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView.swift b/EhPanda/View/Setting/EhSetting/EhSettingView.swift index b9a37a9a..e64686f2 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingView.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView.swift @@ -51,6 +51,7 @@ struct EhSettingView: View { } .sheet(item: $store.route.sending(\.setNavigation).webView, id: \.absoluteString) { url in WebView(url: url) + .ignoresSafeArea(edges: .bottom) .autoBlur(radius: blurRadius) } .toolbar(content: toolbar) diff --git a/EhPanda/View/Setting/Login/LoginView.swift b/EhPanda/View/Setting/Login/LoginView.swift index 62e484cf..7825edb9 100644 --- a/EhPanda/View/Setting/Login/LoginView.swift +++ b/EhPanda/View/Setting/Login/LoginView.swift @@ -73,6 +73,7 @@ struct LoginView: View { WebView(url: route.wrappedValue) { store.send(.loginDone(.success(nil))) } + .ignoresSafeArea(edges: .bottom) .autoBlur(radius: blurRadius) } .onSubmit { From 62ee7521b0289c36c85d6bc103143af5018de736 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 23 Oct 2025 09:32:27 +0800 Subject: [PATCH 38/40] Resolve page tracking issue --- .../View/Reading/Support/AdvancedList.swift | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/EhPanda/View/Reading/Support/AdvancedList.swift b/EhPanda/View/Reading/Support/AdvancedList.swift index 24ea6fca..22eb2bcf 100644 --- a/EhPanda/View/Reading/Support/AdvancedList.swift +++ b/EhPanda/View/Reading/Support/AdvancedList.swift @@ -9,6 +9,7 @@ import SwiftUIPager struct AdvancedList: View where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { @State var performingChanges = false + @State var scrollPositionID: Int? private let pagerModel: Page private let data: [Element] @@ -35,24 +36,16 @@ where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { ScrollView(showsIndicators: false) { LazyVStack(spacing: spacing) { ForEach(data, id: id) { index in - let longPress = longPressGesture(index: index) - let gestures = longPress.simultaneously(with: gesture) - content(index).gesture(gestures) + content(index) + .gesture(gesture) } } - .onAppear { tryScrollTo(id: pagerModel.index + 1, proxy: proxy) } + .scrollTargetLayout() + .onAppear(perform: { tryScrollTo(id: pagerModel.index + 1, proxy: proxy) }) } - .onChange(of: pagerModel.index) { _, newValue in - tryScrollTo(id: newValue + 1, proxy: proxy) - } - } - } - - private func longPressGesture(index: Element) -> some Gesture { - // Setting `minimumDuration` to zero will block ScrollView interaction - LongPressGesture(minimumDuration: 0.5, maximumDistance: .infinity) - .onEnded { _ in - if let index = index as? Int { + .scrollPosition(id: $scrollPositionID, anchor: .center) + .onScrollPhaseChange { _, newValue in + if newValue == .idle, let index = scrollPositionID { performingChanges = true pagerModel.update(.new(index: index - 1)) DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { @@ -60,13 +53,15 @@ where PageView: View, Element: Equatable, ID: Hashable, G: Gesture { } } } + .onChange(of: pagerModel.index) { _, newValue in + tryScrollTo(id: newValue + 1, proxy: proxy) + } + } } private func tryScrollTo(id: Int, proxy: ScrollViewProxy) { if !performingChanges { - AppUtil.dispatchMainSync { - proxy.scrollTo(id, anchor: .center) - } + scrollPositionID = id } } } From 8b2eecf2a50043a24e2282ea6d232687292f48a7 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 23 Oct 2025 09:33:07 +0800 Subject: [PATCH 39/40] Apply glass effect to detail page --- EhPanda/View/Detail/DetailView.swift | 50 ++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index 6c58532d..21d5d72f 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -325,34 +325,48 @@ private struct HeaderSection: View { var body: some View { HStack { KFImage(gallery.coverURL) - .placeholder { Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) } - .defaultModifier().scaledToFit() + .placeholder({ Placeholder(style: .activity(ratio: Defaults.ImageSize.headerAspect)) }) + .defaultModifier() + .scaledToFit() .frame( width: Defaults.ImageSize.headerW, height: Defaults.ImageSize.headerH ) + VStack(alignment: .leading) { Button(action: showFullTitleAction) { Text(title) - .font(.title3.bold()).multilineTextAlignment(.leading) - .tint(.primary).lineLimit(showFullTitle ? nil : 3) + .font(.title3.bold()) + .multilineTextAlignment(.leading) + .tint(.primary) + .lineLimit(showFullTitle ? nil : 3) .fixedSize(horizontal: false, vertical: true) } + Button(gallery.uploader ?? "", action: navigateUploaderAction) - .lineLimit(1).font(.callout).foregroundStyle(.secondary) + .lineLimit(1) + .font(.callout) + .foregroundStyle(.secondary) + Spacer() + HStack { CategoryLabel( - text: gallery.category.value, color: gallery.color, - font: .headline, insets: .init(top: 2, leading: 4, bottom: 2, trailing: 4), + text: gallery.category.value, + color: gallery.color, + font: .headline, + insets: .init(top: 2, leading: 4, bottom: 2, trailing: 4), cornerRadius: 3 ) + Spacer() + ZStack { Button(action: unfavorAction) { Image(systemSymbol: .heartFill) } .opacity(galleryDetail.isFavorited ? 1 : 0) + Menu { ForEach(0..<10) { index in Button(user.getFavoriteCategory(index: index)) { @@ -364,15 +378,19 @@ private struct HeaderSection: View { } .opacity(galleryDetail.isFavorited ? 0 : 1) } - .imageScale(.large).foregroundStyle(.tint) + .imageScale(.large) + .foregroundStyle(.tint) + .buttonStyle(.glass(.regular.interactive())) .disabled(!CookieUtil.didLogin) + Button(action: navigateReadingAction) { Text(L10n.Localizable.DetailView.Button.read) .bold().textCase(.uppercase).font(.headline) .foregroundColor(.white).padding(.vertical, -2) .padding(.horizontal, 2).lineLimit(1) } - .buttonStyle(.borderedProminent).buttonBorderShape(.capsule) + .buttonStyle(.glassProminent) + .buttonBorderShape(.capsule) } .minimumScaleFactor(0.5) } @@ -854,15 +872,21 @@ private struct CommentButton: View { } var body: some View { + let shape = RoundedRectangle(cornerRadius: 15) + Button(action: action) { HStack { - Spacer() Image(systemSymbol: .squareAndPencil) - Text(L10n.Localizable.DetailView.Button.postComment).bold() - Spacer() + + Text(L10n.Localizable.DetailView.Button.postComment) + .bold() } - .padding().background(backgroundColor).cornerRadius(15) + .padding() + .frame(maxWidth: .infinity) + .background(backgroundColor) + .clipShape(shape) } + .glassEffect(.clear.interactive(), in: shape) } } From 8944c3d2b378a22ad0be86e7d68e879cbd58db14 Mon Sep 17 00:00:00 2001 From: Chihchy Date: Thu, 23 Oct 2025 10:53:03 +0800 Subject: [PATCH 40/40] Update dependencies --- .../xcshareddata/swiftpm/Package.resolved | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8dfd001f..a94821f1 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "2d60d4082dfb4978974307acf0f00dfa20e5f621", - "version" : "1.22.3" + "revision" : "a9c3fecb5d31fc8aad5d8ba5d830924966d7fb15", + "version" : "1.23.0" } }, { @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "91415670c91d41e8e1872ef6fe1bf118e20dee37", - "version" : "2.5.1" + "revision" : "bf498690e1f6b4af790260f542e8428a4ba10d78", + "version" : "2.6.0" } }, { @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "30721accd0370d7c9cb5bd0f7cdf5a1a767b383d", - "version" : "2.0.8" + "revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4", + "version" : "2.0.9" } }, {