From 798faa43f64ce15639e4aecc862be72bed129237 Mon Sep 17 00:00:00 2001 From: Doraemoe Date: Wed, 13 Nov 2024 21:01:32 +1000 Subject: [PATCH] Rewrite major components with UIKit (#239) * minimal working * sort and details * paging * jump to page * category * search * tag nav * update --- .github/workflows/ci.yml | 6 +- Gemfile.lock | 48 +-- LANreader.xcodeproj/project.pbxproj | 64 ++- .../xcshareddata/swiftpm/Package.resolved | 36 +- .../Category/CategoryArchiveListV2.swift | 14 +- LANreader/Category/CategoryListV2.swift | 83 ++-- LANreader/Category/EditCategory.swift | 10 +- LANreader/Category/NewCategory.swift | 8 +- .../Category/UICategoryArchiveGrid.swift | 58 +++ LANreader/Category/UICategoryList.swift | 64 +++ LANreader/Common/ArchiveGridV2.swift | 10 +- LANreader/Common/ArchiveListV2.swift | 10 +- LANreader/Common/LockScreen.swift | 8 +- LANreader/Common/UIArchiveCell.swift | 31 ++ LANreader/Common/UIArchiveList.swift | 389 ++++++++++++++++++ LANreader/ContentView.swift | 158 +++---- LANreader/Database/Record.swift | 4 +- LANreader/LANraragiConfigView.swift | 10 +- LANreader/Library/CacheView.swift | 8 +- LANreader/Library/LibraryListV2.swift | 8 +- LANreader/Library/RandomView.swift | 8 +- LANreader/Library/UILibraryList.swift | 52 +++ LANreader/Models/AppModels.swift | 3 +- LANreader/Models/LANraragiResponse.swift | 10 +- LANreader/Models/ViewModels.swift | 8 +- LANreader/Page/ArchiveDetailsV2.swift | 135 ++++-- LANreader/Page/ArchiveReader.swift | 275 +++---------- LANreader/Page/AutomaticPageConfig.swift | 8 +- LANreader/Page/PageImageV2.swift | 76 ++-- LANreader/Page/UIArchiveDetails.swift | 35 ++ LANreader/Page/UIArchiveReader.swift | 87 ++++ LANreader/Page/UIPageCell.swift | 185 +++++++++ LANreader/Page/UIPageCollection.swift | 280 +++++++++++++ LANreader/ReadSettings.swift | 8 +- LANreader/Search/SearchViewV2.swift | 22 +- LANreader/Search/UISearchView.swift | 179 ++++++++ LANreader/Service/ImageService.swift | 44 +- LANreader/Service/LANraragiService.swift | 21 +- LANreader/Setting/DatabaseSettings.swift | 8 +- LANreader/Setting/LogView.swift | 8 +- LANreader/Setting/UploadView.swift | 8 +- LANreader/SettingsView.swift | 10 +- LANreader/ViewSettings.swift | 10 +- .../Resources/ServerInfoResponse.json | 24 +- .../Service/LANraragiServiceTest.swift | 6 +- 45 files changed, 1898 insertions(+), 639 deletions(-) create mode 100644 LANreader/Category/UICategoryArchiveGrid.swift create mode 100644 LANreader/Category/UICategoryList.swift create mode 100644 LANreader/Common/UIArchiveCell.swift create mode 100644 LANreader/Common/UIArchiveList.swift create mode 100644 LANreader/Library/UILibraryList.swift create mode 100644 LANreader/Page/UIArchiveDetails.swift create mode 100644 LANreader/Page/UIArchiveReader.swift create mode 100644 LANreader/Page/UIPageCell.swift create mode 100644 LANreader/Page/UIPageCollection.swift create mode 100644 LANreader/Search/UISearchView.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af44986..e2d7c59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,11 @@ on: jobs: verify: - runs-on: macOS-14 + runs-on: macOS-15 strategy: matrix: - destination: ['platform=iOS Simulator,OS=17.5,name=iPad Pro (11-inch) (4th generation)'] + destination: ['platform=iOS Simulator,OS=18.0,name=iPad Pro 11-inch (M4)'] steps: - uses: actions/checkout@v4 @@ -21,7 +21,7 @@ jobs: run: swiftlint --strict - name: Select Xcode version - run: sudo xcode-select -switch /Applications/Xcode_15.4.app + run: sudo xcode-select -switch /Applications/Xcode_16.app - name: Build and test run: xcodebuild clean test -project LANreader.xcodeproj -scheme LANreader -destination "${destination}" -skipMacroValidation diff --git a/Gemfile.lock b/Gemfile.lock index dede518..df97b91 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,20 +10,20 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.968.0) - aws-sdk-core (3.202.0) + aws-partitions (1.1001.0) + aws-sdk-core (3.211.0) aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.651.0) + aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.88.0) - aws-sdk-core (~> 3, >= 3.201.0) + aws-sdk-kms (1.95.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.159.0) - aws-sdk-core (~> 3, >= 3.201.0) + aws-sdk-s3 (1.169.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.9.1) + aws-sigv4 (1.10.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.2.0) @@ -38,8 +38,8 @@ GEM domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.111.0) - faraday (1.10.3) + excon (0.112.0) + faraday (1.10.4) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -65,10 +65,10 @@ GEM faraday-patron (1.0.0) faraday-rack (1.0.0) faraday-retry (1.0.3) - faraday_middleware (1.2.0) + faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.3.1) - fastlane (2.222.0) + fastlane (2.225.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -84,6 +84,7 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) @@ -109,6 +110,8 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) gh_inspector (1.1.3) google-apis-androidpublisher_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) @@ -151,14 +154,14 @@ GEM domain_name (~> 0.5) httpclient (2.8.3) jmespath (1.6.2) - json (2.7.2) - jwt (2.8.2) + json (2.7.6) + jwt (2.9.3) base64 mini_magick (4.13.2) mini_mime (1.1.5) multi_json (1.15.0) multipart-post (2.4.1) - nanaimo (0.3.0) + nanaimo (0.4.0) naturally (2.2.1) nkf (0.2.0) optparse (0.5.0) @@ -171,8 +174,7 @@ GEM trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.3.6) - strscan + rexml (3.3.9) rouge (2.0.7) ruby2_keywords (0.0.5) rubyzip (2.3.2) @@ -185,7 +187,7 @@ GEM simctl (1.6.10) CFPropertyList naturally - strscan (3.1.0) + sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) @@ -195,15 +197,15 @@ GEM tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) - unicode-display_width (2.5.0) + unicode-display_width (2.6.0) word_wrap (1.0.0) - xcodeproj (1.25.0) + xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (>= 3.3.2, < 4.0) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) xcpretty (0.3.0) rouge (~> 2.0.7) xcpretty-travis-formatter (1.0.1) @@ -216,4 +218,4 @@ DEPENDENCIES fastlane BUNDLED WITH - 2.5.16 + 2.5.22 diff --git a/LANreader.xcodeproj/project.pbxproj b/LANreader.xcodeproj/project.pbxproj index cb66566..9f45dfd 100644 --- a/LANreader.xcodeproj/project.pbxproj +++ b/LANreader.xcodeproj/project.pbxproj @@ -42,10 +42,15 @@ 7DF7EA742564E1D2003B5A5A /* AppDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DF7EA732564E1D2003B5A5A /* AppDatabase.swift */; }; 7DF7EA7F2564E5D1003B5A5A /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DF7EA7E2564E5D1003B5A5A /* Persistence.swift */; }; 7DF7EA852564E675003B5A5A /* Record.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DF7EA842564E675003B5A5A /* Record.swift */; }; + E902FC4E2CD870530071106B /* UICategoryList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E902FC4D2CD8704E0071106B /* UICategoryList.swift */; }; + E902FC502CD87A780071106B /* UICategoryArchiveGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = E902FC4F2CD87A710071106B /* UICategoryArchiveGrid.swift */; }; + E902FC522CD887DD0071106B /* UISearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E902FC512CD887DA0071106B /* UISearchView.swift */; }; E90576B629F6BF8700C3CE3F /* WrappingHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E90576B529F6BF8700C3CE3F /* WrappingHStack.swift */; }; E90576BC29F7FB2900C3CE3F /* DraggableAndZoomableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E90576BB29F7FB2900C3CE3F /* DraggableAndZoomableView.swift */; }; E90576BF29F8C10700C3CE3F /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = E90576BE29F8C10700C3CE3F /* Puppy */; }; E90C26CB2C1A7A6D00BE87D9 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = E90C26CA2C1A7A6D00BE87D9 /* ComposableArchitecture */; }; + E91EE6832CAB78ED0046853C /* UIArchiveReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E91EE6822CAB78E80046853C /* UIArchiveReader.swift */; }; + E91EE6852CAB94FC0046853C /* UIPageCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E91EE6842CAB94F60046853C /* UIPageCollection.swift */; }; E926CE7A2B1B5474000E0B10 /* NewCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E926CE792B1B5474000E0B10 /* NewCategory.swift */; }; E94C1DDC2A204DDA00552ABD /* ImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E94C1DDB2A204DDA00552ABD /* ImageService.swift */; }; E9623E6D2BF583FD005FD609 /* CacheView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9623E6C2BF583FD005FD609 /* CacheView.swift */; }; @@ -58,12 +63,17 @@ E98B8B902A0BA9D1004CBBC0 /* UploadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98B8B8F2A0BA9D1004CBBC0 /* UploadView.swift */; }; E997D8002AF20EE2007C91F1 /* ArchiveGridV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = E997D7FF2AF20EE2007C91F1 /* ArchiveGridV2.swift */; }; E997D8042AF3C9BA007C91F1 /* LibraryListV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = E997D8032AF3C9BA007C91F1 /* LibraryListV2.swift */; }; + E998836C2CBCE76A00A81F60 /* UIArchiveDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = E998836B2CBCE76300A81F60 /* UIArchiveDetails.swift */; }; E99D2B412B0176410029FDEC /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = E99D2B402B0176410029FDEC /* InfoPlist.xcstrings */; }; E9A8612D2BF31534003534FA /* AutomaticPageConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9A8612C2BF31534003534FA /* AutomaticPageConfig.swift */; }; + E9AAC1352CBC8FC600E5E89A /* UIPageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9AAC1342CBC8FC600E5E89A /* UIPageCell.swift */; }; E9B39A6E2B21E7A800700FE4 /* RandomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B39A6D2B21E7A800700FE4 /* RandomView.swift */; }; E9BE57B22942CFEC00A3AEA0 /* LockScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BE57B12942CFEC00A3AEA0 /* LockScreen.swift */; }; E9BFA2052AF51DB500DDE107 /* ArchiveReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BFA2042AF51DB500DDE107 /* ArchiveReader.swift */; }; E9BFA2072AF52C3300DDE107 /* PageImageV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BFA2062AF52C3300DDE107 /* PageImageV2.swift */; }; + E9C354E22CA64161004C10CD /* UIArchiveList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C354E12CA6415E004C10CD /* UIArchiveList.swift */; }; + E9C354E42CA646D7004C10CD /* UILibraryList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C354E32CA646D3004C10CD /* UILibraryList.swift */; }; + E9C3550F2CA65101004C10CD /* UIArchiveCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C3550E2CA650FE004C10CD /* UIArchiveCell.swift */; }; E9CBC0F02A3DD62F00CCD592 /* LogFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9CBC0EF2A3DD62F00CCD592 /* LogFormatter.swift */; }; E9DA93612C16960E003272B9 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = E9DA93602C16960E003272B9 /* Localizable.xcstrings */; }; E9DAEC552B07295D00ADC215 /* ArchiveDetailsV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DAEC542B07295D00ADC215 /* ArchiveDetailsV2.swift */; }; @@ -151,8 +161,13 @@ 7DF7EA732564E1D2003B5A5A /* AppDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDatabase.swift; sourceTree = ""; }; 7DF7EA7E2564E5D1003B5A5A /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; 7DF7EA842564E675003B5A5A /* Record.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Record.swift; sourceTree = ""; }; + E902FC4D2CD8704E0071106B /* UICategoryList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UICategoryList.swift; sourceTree = ""; }; + E902FC4F2CD87A710071106B /* UICategoryArchiveGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UICategoryArchiveGrid.swift; sourceTree = ""; }; + E902FC512CD887DA0071106B /* UISearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UISearchView.swift; sourceTree = ""; }; E90576B529F6BF8700C3CE3F /* WrappingHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappingHStack.swift; sourceTree = ""; }; E90576BB29F7FB2900C3CE3F /* DraggableAndZoomableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableAndZoomableView.swift; sourceTree = ""; }; + E91EE6822CAB78E80046853C /* UIArchiveReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIArchiveReader.swift; sourceTree = ""; }; + E91EE6842CAB94F60046853C /* UIPageCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIPageCollection.swift; sourceTree = ""; }; E926CE792B1B5474000E0B10 /* NewCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewCategory.swift; sourceTree = ""; }; E94C1DDB2A204DDA00552ABD /* ImageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageService.swift; sourceTree = ""; }; E9623E6C2BF583FD005FD609 /* CacheView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheView.swift; sourceTree = ""; }; @@ -166,13 +181,18 @@ E98B8B8F2A0BA9D1004CBBC0 /* UploadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadView.swift; sourceTree = ""; }; E997D7FF2AF20EE2007C91F1 /* ArchiveGridV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveGridV2.swift; sourceTree = ""; }; E997D8032AF3C9BA007C91F1 /* LibraryListV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListV2.swift; sourceTree = ""; }; + E998836B2CBCE76300A81F60 /* UIArchiveDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIArchiveDetails.swift; sourceTree = ""; }; E99D2B3F2B0176400029FDEC /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/LaunchScreen.xcstrings; sourceTree = ""; }; E99D2B402B0176410029FDEC /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; E9A8612C2BF31534003534FA /* AutomaticPageConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticPageConfig.swift; sourceTree = ""; }; + E9AAC1342CBC8FC600E5E89A /* UIPageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIPageCell.swift; sourceTree = ""; }; E9B39A6D2B21E7A800700FE4 /* RandomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomView.swift; sourceTree = ""; }; E9BE57B12942CFEC00A3AEA0 /* LockScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LockScreen.swift; path = LANreader/Common/LockScreen.swift; sourceTree = SOURCE_ROOT; }; E9BFA2042AF51DB500DDE107 /* ArchiveReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveReader.swift; sourceTree = ""; }; E9BFA2062AF52C3300DDE107 /* PageImageV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImageV2.swift; sourceTree = ""; }; + E9C354E12CA6415E004C10CD /* UIArchiveList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIArchiveList.swift; sourceTree = ""; }; + E9C354E32CA646D3004C10CD /* UILibraryList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UILibraryList.swift; sourceTree = ""; }; + E9C3550E2CA650FE004C10CD /* UIArchiveCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIArchiveCell.swift; sourceTree = ""; }; E9CBC0EF2A3DD62F00CCD592 /* LogFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogFormatter.swift; sourceTree = ""; }; E9DA93602C16960E003272B9 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; E9DAEC542B07295D00ADC215 /* ArchiveDetailsV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveDetailsV2.swift; sourceTree = ""; }; @@ -237,6 +257,7 @@ 4BD8240793F86C42AF3383BB /* Library */ = { isa = PBXGroup; children = ( + E9C354E32CA646D3004C10CD /* UILibraryList.swift */, E997D8032AF3C9BA007C91F1 /* LibraryListV2.swift */, E9B39A6D2B21E7A800700FE4 /* RandomView.swift */, E9623E6C2BF583FD005FD609 /* CacheView.swift */, @@ -247,6 +268,10 @@ 4BD824C107853A5BC514F2AD /* Page */ = { isa = PBXGroup; children = ( + E998836B2CBCE76300A81F60 /* UIArchiveDetails.swift */, + E9AAC1342CBC8FC600E5E89A /* UIPageCell.swift */, + E91EE6842CAB94F60046853C /* UIPageCollection.swift */, + E91EE6822CAB78E80046853C /* UIArchiveReader.swift */, E9DAEC542B07295D00ADC215 /* ArchiveDetailsV2.swift */, E9BFA2062AF52C3300DDE107 /* PageImageV2.swift */, E90576B529F6BF8700C3CE3F /* WrappingHStack.swift */, @@ -270,6 +295,8 @@ 4BD826D5E1C22874FD2F7426 /* Common */ = { isa = PBXGroup; children = ( + E9C3550E2CA650FE004C10CD /* UIArchiveCell.swift */, + E9C354E12CA6415E004C10CD /* UIArchiveList.swift */, E9BE57B12942CFEC00A3AEA0 /* LockScreen.swift */, 6DA71C5CFBC9FDA5C1244254 /* LoadingView.swift */, E9DCE8982AF0665900C17236 /* ArchiveListV2.swift */, @@ -296,8 +323,10 @@ 4BD82C9AD1A1D57332A10917 /* Category */ = { isa = PBXGroup; children = ( + E902FC4F2CD87A710071106B /* UICategoryArchiveGrid.swift */, E9F3B0132B01D2DB00F60F62 /* CategoryArchiveListV2.swift */, E9F3B0112B01A21C00F60F62 /* CategoryListV2.swift */, + E902FC4D2CD8704E0071106B /* UICategoryList.swift */, E926CE792B1B5474000E0B10 /* NewCategory.swift */, E9EB422A2C05BE0B00E49AE1 /* EditCategory.swift */, ); @@ -307,6 +336,7 @@ 4BD82F8526B52356C24BC491 /* Search */ = { isa = PBXGroup; children = ( + E902FC512CD887DA0071106B /* UISearchView.swift */, E9DCE8A12AF0795600C17236 /* SearchViewV2.swift */, ); path = Search; @@ -637,16 +667,20 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E91EE6852CAB94FC0046853C /* UIPageCollection.swift in Sources */, E9F3B0122B01A21C00F60F62 /* CategoryListV2.swift in Sources */, 7DF7EA742564E1D2003B5A5A /* AppDatabase.swift in Sources */, E9623E6D2BF583FD005FD609 /* CacheView.swift in Sources */, E9F3B0142B01D2DB00F60F62 /* CategoryArchiveListV2.swift in Sources */, + E902FC522CD887DD0071106B /* UISearchView.swift in Sources */, E9DCE8A22AF0795600C17236 /* SearchViewV2.swift in Sources */, + E91EE6832CAB78ED0046853C /* UIArchiveReader.swift in Sources */, E926CE7A2B1B5474000E0B10 /* NewCategory.swift in Sources */, 7D3DFF16251777B200686612 /* LANreaderApp.swift in Sources */, E94C1DDC2A204DDA00552ABD /* ImageService.swift in Sources */, E9CBC0F02A3DD62F00CCD592 /* LogFormatter.swift in Sources */, 7DF7EA852564E675003B5A5A /* Record.swift in Sources */, + E9C354E42CA646D7004C10CD /* UILibraryList.swift in Sources */, 7D354D0524F2387900617F4A /* ViewModels.swift in Sources */, 7D354CDA24F13C4900617F4A /* LANraragiResponse.swift in Sources */, E90576BC29F7FB2900C3CE3F /* DraggableAndZoomableView.swift in Sources */, @@ -660,16 +694,22 @@ E9EB422B2C05BE0B00E49AE1 /* EditCategory.swift in Sources */, 4BD823AC73ED9E45F8B7E436 /* ReadSettings.swift in Sources */, 4BD82F2BEC56B95FB9BC9841 /* SettingsView.swift in Sources */, + E902FC502CD87A780071106B /* UICategoryArchiveGrid.swift in Sources */, + E998836C2CBCE76A00A81F60 /* UIArchiveDetails.swift in Sources */, 4BD82C7F9CB71CAF8F33C171 /* ViewSettings.swift in Sources */, 4BD82CED54A4C9689B0E9E55 /* ContentView.swift in Sources */, E997D8002AF20EE2007C91F1 /* ArchiveGridV2.swift in Sources */, + E9C354E22CA64161004C10CD /* UIArchiveList.swift in Sources */, + E9C3550F2CA65101004C10CD /* UIArchiveCell.swift in Sources */, E90576B629F6BF8700C3CE3F /* WrappingHStack.swift in Sources */, E9BE57B22942CFEC00A3AEA0 /* LockScreen.swift in Sources */, E9DCE8992AF0665900C17236 /* ArchiveListV2.swift in Sources */, E9BFA2072AF52C3300DDE107 /* PageImageV2.swift in Sources */, 7DE399892578FB34003DC5F7 /* DatabaseSettings.swift in Sources */, 7DF7EA7F2564E5D1003B5A5A /* Persistence.swift in Sources */, + E902FC4E2CD870530071106B /* UICategoryList.swift in Sources */, E997D8042AF3C9BA007C91F1 /* LibraryListV2.swift in Sources */, + E9AAC1352CBC8FC600E5E89A /* UIPageCell.swift in Sources */, E9E3631F26F471E400C58D01 /* LogView.swift in Sources */, 6DA713301F56AA72BA0B4BE3 /* ServerSettings.swift in Sources */, E9BFA2052AF51DB500DDE107 /* ArchiveReader.swift in Sources */, @@ -853,7 +893,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 112; + CURRENT_PROJECT_VERSION = 113; DEVELOPMENT_ASSET_PATHS = "\"LANreader/Preview Content\""; DEVELOPMENT_TEAM = UUEBW58SA6; ENABLE_PREVIEWS = YES; @@ -865,8 +905,8 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.1; - PRODUCT_BUNDLE_IDENTIFIER = com.jif.LANreader; + MARKETING_VERSION = 3.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jif.LANreader.3; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; @@ -881,7 +921,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 112; + CURRENT_PROJECT_VERSION = 113; DEVELOPMENT_ASSET_PATHS = "\"LANreader/Preview Content\""; DEVELOPMENT_TEAM = UUEBW58SA6; ENABLE_PREVIEWS = YES; @@ -893,8 +933,8 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.1; - PRODUCT_BUNDLE_IDENTIFIER = com.jif.LANreader; + MARKETING_VERSION = 3.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jif.LANreader.3; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; @@ -958,7 +998,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 112; + CURRENT_PROJECT_VERSION = 113; DEVELOPMENT_TEAM = UUEBW58SA6; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Action/Info.plist; @@ -970,8 +1010,8 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.1; - PRODUCT_BUNDLE_IDENTIFIER = com.jif.LANreader.Action; + MARKETING_VERSION = 3.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jif.LANreader.3.Action; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -987,7 +1027,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 112; + CURRENT_PROJECT_VERSION = 113; DEVELOPMENT_TEAM = UUEBW58SA6; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Action/Info.plist; @@ -999,8 +1039,8 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.1; - PRODUCT_BUNDLE_IDENTIFIER = com.jif.LANreader.Action; + MARKETING_VERSION = 3.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jif.LANreader.3.Action; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/LANreader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LANreader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 452c6b2..7556a50 100644 --- a/LANreader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LANreader.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Alamofire/Alamofire.git", "state" : { - "revision" : "f455c2975872ccd2d9c81594c658af65716e9b9a", - "version" : "5.9.1" + "revision" : "e16d3481f5ed35f0472cb93350085853d754913f", + "version" : "5.10.1" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "642e6aab8e03e5f992d9c83e38c5be98cfad5078", - "version" : "1.5.5" + "revision" : "bc92c4b27f9a84bfb498cdbfdf35d5a357e9161f", + "version" : "1.5.6" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", - "version" : "1.1.3" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "8013f1a72af8ccb2b1735d7aed831a8dc07c6fd0", - "version" : "1.15.0" + "revision" : "a952dde0bf2b13048843c8dcc72cf21737d67b0c", + "version" : "1.16.0" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", - "version" : "1.1.0" + "revision" : "163409ef7dae9d960b87f34b51587b6609a76c1f", + "version" : "1.3.0" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "fd1fb25b68fdb9756cd61d23dbd9e2614b340085", - "version" : "1.4.0" + "revision" : "28f82715ccd6799d7ed46c0720c23c768ef6b55c", + "version" : "1.5.1" } }, { @@ -159,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "d1bdbd8a5d1d1dfd2e4bb1f5e2f6facb631404d4", - "version" : "2.2.1" + "revision" : "16a27ab7ae0abfefbbcba73581b3e2380b47a579", + "version" : "2.2.2" } }, { @@ -177,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "cb53fa1bd3219b0b23ded7dfdd3b2baff266fd25", - "version" : "600.0.0" + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } }, { @@ -186,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "bc2a151366f2cd0e347274544933bc2acb00c9fe", - "version" : "1.4.0" + "revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb", + "version" : "1.4.2" } } ], diff --git a/LANreader/Category/CategoryArchiveListV2.swift b/LANreader/Category/CategoryArchiveListV2.swift index fa9657c..69e1436 100644 --- a/LANreader/Category/CategoryArchiveListV2.swift +++ b/LANreader/Category/CategoryArchiveListV2.swift @@ -2,25 +2,28 @@ import ComposableArchitecture import Logging import SwiftUI -@Reducer struct CategoryArchiveListFeature { +@Reducer public struct CategoryArchiveListFeature { private let logger = Logger(label: "CategoryArchiveListFeature") @ObservableState - struct State: Equatable { + public struct State: Equatable { + @Shared(.inMemory(SettingsKey.tabBarHidden)) var tabBarHidden = false + var id: String var name: String var archiveList: ArchiveListFeature.State } - enum Action: Equatable, BindableAction { + public enum Action: Equatable, BindableAction { case binding(BindingAction) case archiveList(ArchiveListFeature.Action) case toggleSelectMode + case setTabBarHidden(Bool) } - var body: some ReducerOf { + public var body: some ReducerOf { BindingReducer() Scope(state: \.archiveList, action: \.archiveList) { @@ -40,6 +43,9 @@ import SwiftUI return .none case .archiveList: return .none + case let .setTabBarHidden(hidden): + state.tabBarHidden = hidden + return .none } } } diff --git a/LANreader/Category/CategoryListV2.swift b/LANreader/Category/CategoryListV2.swift index c481499..6af961c 100644 --- a/LANreader/Category/CategoryListV2.swift +++ b/LANreader/Category/CategoryListV2.swift @@ -3,22 +3,23 @@ import Logging import SwiftUI import NotificationBannerSwift -@Reducer struct CategoryFeature { +@Reducer public struct CategoryFeature { private let logger = Logger(label: "CategoryFeature") @ObservableState - struct State: Equatable { + public struct State: Equatable { @Presents var destination: Destination.State? @SharedReader(.appStorage(SettingsKey.lanraragiUrl)) var lanraragiUrl = "" @Shared(.category) var categoryItems: IdentifiedArrayOf = [] + @Shared(.inMemory(SettingsKey.tabBarHidden)) var tabBarHidden = false var editMode: EditMode = .inactive var showLoading = false var errorMessage = "" } - enum Action: BindableAction { + public enum Action: BindableAction { case destination(PresentationAction) case binding(BindingAction) @@ -28,11 +29,12 @@ import NotificationBannerSwift case setErrorMessage(String) case showAddCategory case showEditCategory(CategoryItem) + case setTabBarHidden(Bool) } @Dependency(\.lanraragiService) var service - var body: some ReducerOf { + public var body: some ReducerOf { BindingReducer() Reduce { state, action in @@ -87,6 +89,9 @@ import NotificationBannerSwift return .run { send in await send(.loadCategory(true)) } + case let .setTabBarHidden(hidden): + state.tabBarHidden = hidden + return .none default: return .none } @@ -95,7 +100,7 @@ import NotificationBannerSwift } @Reducer(state: .equatable) - enum Destination { + public enum Destination { case add(NewCategoryFeature) case edit(EditCategoryFeature) } @@ -103,6 +108,7 @@ import NotificationBannerSwift struct CategoryListV2: View { @Bindable var store: StoreOf + let onTapCategory: (StoreOf) -> Void var body: some View { if store.showLoading { @@ -117,29 +123,30 @@ struct CategoryListV2: View { categoryItem(store: store, item: item) } } - .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - Image(systemName: "plus.circle") - .onTapGesture { - store.send(.showAddCategory) - } - .foregroundStyle(Color.accentColor) - .popover( - item: $store.scope(state: \.destination?.add, action: \.destination.add) - ) { store in - NewCategory(store: store) - } - .opacity(store.editMode == .active ? 1 : 0) - EditButton() - } - } - .sheet(item: $store.scope(state: \.destination?.edit, action: \.destination.edit), content: { store in - EditCategory(store: store) - }) - .toolbar(store.editMode == .active ? .hidden : .visible, for: .tabBar) - .environment(\.editMode, $store.editMode) - .navigationTitle("category") - .navigationBarTitleDisplayMode(.inline) +// .toolbar { +// ToolbarItemGroup(placement: .topBarTrailing) { +// Image(systemName: "plus.circle") +// .onTapGesture { +// store.send(.showAddCategory) +// } +// .foregroundStyle(Color.accentColor) +// .popover( +// item: $store.scope(state: \.destination?.add, action: \.destination.add) +// ) { store in +// NewCategory(store: store) +// } +// .opacity(store.editMode == .active ? 1 : 0) +// EditButton() +// } +// } +// .sheet(item: $store.scope(state: \.destination?.edit, action: \.destination.edit), content: { store in +// EditCategory(store: store) +// }) +// .toolbar(store.editMode == .active ? .hidden : .visible, for: .tabBar) +// .environment(\.editMode, $store.editMode) +// .navigationTitle("category") +// .navigationBarTitleDisplayMode(.inline) + .toolbar(store.tabBarHidden ? .hidden : .visible, for: .tabBar) .task { if store.categoryItems.isEmpty { store.send(.loadCategory(true)) @@ -177,14 +184,12 @@ struct CategoryListV2: View { Image(systemName: store.editMode == .active ? "square.and.pencil" : "chevron.right") } .contentShape(Rectangle()) - .allowsHitTesting(store.editMode == .active) .onTapGesture { - store.send(.showEditCategory(item)) - } - .background { - NavigationLink( - "", state: AppFeature.Path.State.categoryArchiveList( - CategoryArchiveListFeature.State.init( + if store.editMode == .active { + store.send(.showEditCategory(item)) + } else { + let categoryArchiveListStore = Store( + initialState: CategoryArchiveListFeature.State.init( id: item.id, name: item.name, archiveList: ArchiveListFeature.State( @@ -192,9 +197,11 @@ struct CategoryListV2: View { currentTab: .category ) ) - ) - ) - .opacity(0) + ) { + CategoryArchiveListFeature() + } + onTapCategory(categoryArchiveListStore) + } } } } diff --git a/LANreader/Category/EditCategory.swift b/LANreader/Category/EditCategory.swift index c9e4fac..865a926 100644 --- a/LANreader/Category/EditCategory.swift +++ b/LANreader/Category/EditCategory.swift @@ -2,11 +2,11 @@ import SwiftUI import ComposableArchitecture import Logging -@Reducer struct EditCategoryFeature { +@Reducer public struct EditCategoryFeature { private let logger = Logger(label: "EditCategoryFeature") @ObservableState - struct State: Equatable { + public struct State: Equatable { @Presents var alert: AlertState? var id: String @@ -18,7 +18,7 @@ import Logging var errorMessage = "" } - enum Action: Equatable, BindableAction { + public enum Action: Equatable, BindableAction { case binding(BindingAction) case alert(PresentationAction) @@ -29,7 +29,7 @@ import Logging case editCategoryCancel case setErrorMessage(String) - enum Alert { + public enum Alert { case confirmDelete } } @@ -37,7 +37,7 @@ import Logging @Dependency(\.lanraragiService) var service @Dependency(\.dismiss) var dismiss - var body: some ReducerOf { + public var body: some ReducerOf { BindingReducer() Reduce { state, action in diff --git a/LANreader/Category/NewCategory.swift b/LANreader/Category/NewCategory.swift index 89cae91..3e095ad 100644 --- a/LANreader/Category/NewCategory.swift +++ b/LANreader/Category/NewCategory.swift @@ -3,18 +3,18 @@ import SwiftUI import Logging import NotificationBannerSwift -@Reducer struct NewCategoryFeature { +@Reducer public struct NewCategoryFeature { private let logger = Logger(label: "NewCategoryFeature") @ObservableState - struct State: Equatable { + public struct State: Equatable { var name = "" var dynamic = false var filter = "" var errorMessage = "" } - enum Action: Equatable, BindableAction { + public enum Action: Equatable, BindableAction { case binding(BindingAction) case addCategoryTapped @@ -24,7 +24,7 @@ import NotificationBannerSwift @Dependency(\.lanraragiService) var service - var body: some ReducerOf { + public var body: some ReducerOf { BindingReducer() Reduce { state, action in diff --git a/LANreader/Category/UICategoryArchiveGrid.swift b/LANreader/Category/UICategoryArchiveGrid.swift new file mode 100644 index 0000000..49de05e --- /dev/null +++ b/LANreader/Category/UICategoryArchiveGrid.swift @@ -0,0 +1,58 @@ +import ComposableArchitecture +import SwiftUI +import UIKit + +public struct UICategoryArchiveGrid: UIViewControllerRepresentable { + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public func makeUIViewController(context: Context) -> UIViewController { + UICategoryArchiveGridController(store: store) + } + + public func updateUIViewController( + _ uiViewController: UIViewController, + context: Context + ) { + // Nothing to do + } +} + +class UICategoryArchiveGridController: UIViewController { + let store: StoreOf + + init(store: StoreOf) { + self.store = store + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.title = store.name + tabBarController?.tabBar.isHidden = true + + let archiveListView = UIArchiveListViewController( + store: store.scope(state: \.archiveList, action: \.archiveList) + ) + add(archiveListView) + NSLayoutConstraint.activate([ + archiveListView.view.topAnchor.constraint(equalTo: view.topAnchor), + archiveListView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + archiveListView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + archiveListView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + store.send(.setTabBarHidden(true)) + } +} diff --git a/LANreader/Category/UICategoryList.swift b/LANreader/Category/UICategoryList.swift new file mode 100644 index 0000000..1112b3c --- /dev/null +++ b/LANreader/Category/UICategoryList.swift @@ -0,0 +1,64 @@ +import ComposableArchitecture +import SwiftUI +import UIKit + +public struct UICategoryList: UIViewControllerRepresentable { + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public func makeUIViewController(context: Context) -> UIViewController { + UINavigationController(rootViewController: UICategoryListViewController(store: store)) + } + + public func updateUIViewController( + _ uiViewController: UIViewController, + context: Context + ) { + // Nothing to do + } +} + +class UICategoryListViewController: UIViewController { + private let store: StoreOf + private var hostingController: UIHostingController! + + init(store: StoreOf) { + self.store = store + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayout() { + self.hostingController = UIHostingController(rootView: CategoryListV2(store: store, onTapCategory: {store in + self.navigationController?.pushViewController( + UICategoryArchiveGridController(store: store), + animated: true + ) + })) +// navigationItem.title = String(localized: "category") +// navigationItem.largeTitleDisplayMode = .inline + add(hostingController) + NSLayoutConstraint.activate([ + hostingController!.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController!.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingController!.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController!.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + override func viewDidLoad() { + super.viewDidLoad() + setupLayout() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + store.send(.setTabBarHidden(false)) + } +} diff --git a/LANreader/Common/ArchiveGridV2.swift b/LANreader/Common/ArchiveGridV2.swift index 62a4450..b8b03e8 100644 --- a/LANreader/Common/ArchiveGridV2.swift +++ b/LANreader/Common/ArchiveGridV2.swift @@ -2,14 +2,14 @@ import ComposableArchitecture import SwiftUI import Logging -@Reducer struct GridFeature { +@Reducer public struct GridFeature { private let logger = Logger(label: "GridFeature") @ObservableState - struct State: Equatable, Identifiable { + public struct State: Equatable, Identifiable { @Shared var archive: ArchiveItem - var id: String { self.archive.id } + public var id: String { self.archive.id } var path: URL? var mode: ThumbnailMode = .loading let cached: Bool @@ -22,7 +22,7 @@ import Logging } } - enum Action: Equatable { + public enum Action: Equatable { case load(Bool) case finishLoading case finishRefreshArchive @@ -32,7 +32,7 @@ import Logging @Dependency(\.imageService) var imageService @Dependency(\.appDatabase) var database - var body: some ReducerOf { + public var body: some ReducerOf { Reduce { state, action in switch action { case let .load(force): diff --git a/LANreader/Common/ArchiveListV2.swift b/LANreader/Common/ArchiveListV2.swift index 0fd05b1..9572573 100644 --- a/LANreader/Common/ArchiveListV2.swift +++ b/LANreader/Common/ArchiveListV2.swift @@ -5,11 +5,11 @@ import Combine import Logging import NotificationBannerSwift -@Reducer struct ArchiveListFeature { +@Reducer public struct ArchiveListFeature { private let logger = Logger(label: "ArchiveListFeature") @ObservableState - struct State: Equatable { + public struct State: Equatable { @Presents var alert: AlertState? @SharedReader(.appStorage(SettingsKey.lanraragiUrl)) var lanraragiUrl = "" @@ -36,7 +36,7 @@ import NotificationBannerSwift var archivesToDisplay: IdentifiedArrayOf = [] } - enum Action: Equatable { + public enum Action: Equatable { case alert(PresentationAction) case grid(IdentifiedActionOf) case loadCategory @@ -65,7 +65,7 @@ import NotificationBannerSwift case deleteSuccess(Set) case removeFromCategoryButtonTapped case removeFromCategorySuccess(Set) - enum Alert { + public enum Alert { case confirmDelete case confirmRemoveFromCategory } @@ -76,7 +76,7 @@ import NotificationBannerSwift enum CancelId { case search } - var body: some ReducerOf { + public var body: some ReducerOf { Reduce { state, action in switch action { case let .setFilter(filter): diff --git a/LANreader/Common/LockScreen.swift b/LANreader/Common/LockScreen.swift index f0551bc..4a4b28d 100644 --- a/LANreader/Common/LockScreen.swift +++ b/LANreader/Common/LockScreen.swift @@ -3,9 +3,9 @@ import SwiftUI import NotificationBannerSwift import LocalAuthentication -@Reducer struct LockScreenFeature { +@Reducer public struct LockScreenFeature { @ObservableState - struct State: Equatable { + public struct State: Equatable { @Shared(.appStorage(SettingsKey.passcode)) var passcode = "" var pin = "" @@ -17,7 +17,7 @@ import LocalAuthentication var errorMessage = "" } - enum Action: Equatable, BindableAction { + public enum Action: Equatable, BindableAction { case binding(BindingAction) case setPin(String) @@ -32,7 +32,7 @@ import LocalAuthentication @Dependency(\.dismiss) var dismiss - var body: some Reducer { + public var body: some Reducer { BindingReducer() Reduce { state, action in diff --git a/LANreader/Common/UIArchiveCell.swift b/LANreader/Common/UIArchiveCell.swift new file mode 100644 index 0000000..5f0965c --- /dev/null +++ b/LANreader/Common/UIArchiveCell.swift @@ -0,0 +1,31 @@ +import UIKit +import ComposableArchitecture +import SwiftUI + +class UIArchiveCell: UICollectionViewCell { + private var hostingController: UIHostingController? + + func configure(with store: StoreOf) { + hostingController?.view.isHidden = false + if hostingController == nil { + let hostingController = UIHostingController(rootView: ArchiveGridV2(store: store)) + hostingController.view.backgroundColor = .clear + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(hostingController.view) + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: contentView.topAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + self.hostingController = hostingController + } else { + hostingController?.rootView = ArchiveGridV2(store: store) + } + } + + override func prepareForReuse() { + super.prepareForReuse() + hostingController?.view.isHidden = true + } +} diff --git a/LANreader/Common/UIArchiveList.swift b/LANreader/Common/UIArchiveList.swift new file mode 100644 index 0000000..30b6154 --- /dev/null +++ b/LANreader/Common/UIArchiveList.swift @@ -0,0 +1,389 @@ +import Combine +import ComposableArchitecture +import SwiftUI +import UIKit + +public struct UIArchiveList: UIViewControllerRepresentable { + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public func makeUIViewController(context: Context) -> UIViewController { + UIArchiveListViewController(store: store) + } + + public func updateUIViewController( + _ uiViewController: UIViewController, + context: Context + ) { + // Nothing to do + } +} + +class UIArchiveListViewController: UICollectionViewController { + let store: StoreOf + + var dataSource: + UICollectionViewDiffableDataSource>! + var isLoading = false + + private let refreshControl = UIRefreshControl() + private var cancellables: Set = [] + + init(store: StoreOf) { + self.store = store + super.init(collectionViewLayout: UICollectionViewFlowLayout()) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setupLayout() { + guard + let layout = collectionView.collectionViewLayout + as? UICollectionViewFlowLayout + else { return } + + layout.minimumInteritemSpacing = 5 + layout.sectionInset = UIEdgeInsets( + top: 10, left: 10, bottom: 10, right: 10) + + if let viewWidth = parent?.view.bounds.width { + if viewWidth / 3 < 160 { + let width = viewWidth / 2 - 15 + layout.itemSize = CGSize( + width: width, height: width / 2 * 3 + 10) + } else { + layout.itemSize = CGSize(width: 190, height: 295) + } + } else { + layout.itemSize = CGSize(width: 180, height: 270) + } + } + + func setupRefresh() { + refreshControl.addTarget( + self, action: #selector(didPullToRefresh(_:)), for: .valueChanged) + collectionView.alwaysBounceVertical = true + collectionView.refreshControl = refreshControl + } + + func setupCollectionView() { + collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + collectionView.backgroundColor = .systemBackground + navigationItem.largeTitleDisplayMode = .inline + collectionView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: self.view.topAnchor), + collectionView.bottomAnchor.constraint( + equalTo: self.view.bottomAnchor), + collectionView.leadingAnchor.constraint( + equalTo: self.view.leadingAnchor), + collectionView.trailingAnchor.constraint( + equalTo: self.view.trailingAnchor) + + ]) + } + + func setupCell() { + collectionView.register( + UIArchiveCell.self, forCellWithReuseIdentifier: "Archive") + collectionView.register( + LoadingReusableView.self, + forSupplementaryViewOfKind: UICollectionView + .elementKindSectionFooter, + withReuseIdentifier: LoadingReusableView.reuseIdentifier) + + let cellRegistration = UICollectionView.CellRegistration< + UIArchiveCell, StoreOf + > { [weak self] cell, _, itemStore in + + guard self != nil else { return } + cell.configure(with: itemStore) + } + + dataSource = UICollectionViewDiffableDataSource< + Section, StoreOf + >(collectionView: collectionView) { collectionView, indexPath, itemStore in + collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, + for: indexPath, + item: itemStore + ) + } + + dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in + let footer = + collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: LoadingReusableView.reuseIdentifier, + for: indexPath) as? LoadingReusableView + if self?.isLoading == true { + footer?.startAnimation() + } else { + footer?.stopAnimation() + } + return footer + } + } + + func setupToolbar() { + let actions = SearchSort.allCases.map { sort in + let localizedKey = "settings.archive.list.order.\(sort)" + let label = NSLocalizedString(localizedKey, comment: "") + let image: UIImage? = + if store.searchSort == sort.rawValue + || (store.searchSort == store.searchSortCustom + && sort == SearchSort.custom) { + if store.searchSortOrder == "asc" { + UIImage(systemName: "arrow.up") + } else { + UIImage(systemName: "arrow.down") + } + } else { + nil + } + return UIAction(title: label, image: image) { [weak self] _ in + guard let self else { return } + if store.searchSort == sort.rawValue + || (store.searchSort == store.searchSortCustom + && sort == SearchSort.custom) { + if store.searchSortOrder == "asc" { + store.send(.setSearchSortOrder("desc")) + } else { + store.send(.setSearchSortOrder("asc")) + } + } else { + if sort == SearchSort.custom { + store.send(.setSearchSort(store.searchSortCustom)) + } else { + store.send(.setSearchSort(sort.rawValue)) + } + } + } + } + let sortGroup = UIMenu( + title: "", options: .displayInline, children: actions) + + let hideReadAction = UIAction( + title: String(localized: "settings.view.hideRead"), + image: store.hideRead ? UIImage(systemName: "checkmark") : nil + ) { [weak self] _ in + guard let self else { return } + store.send(.toggleHideRead) + } + + // Create a menu with the actions + let menu = UIMenu(title: "", children: [sortGroup, hideReadAction]) + let menuButton = UIBarButtonItem( + image: UIImage(systemName: "arrow.up.arrow.down.circle"), menu: menu + ) + parent?.navigationItem.rightBarButtonItem = menuButton + } + + // swiftlint:disable function_body_length + func setupObserve() { + observe { [weak self] in + guard let self else { return } + var snapshot = NSDiffableDataSourceSnapshot< + Section, StoreOf + >() + snapshot.appendSections([.main]) + snapshot.appendItems( + Array(store.scope(state: \.archivesToDisplay, action: \.grid))) + dataSource.apply(snapshot, animatingDifferences: false) + } + + observe { [weak self] in + guard let self else { return } + setupToolbar() + } + + store.publisher.lanraragiUrl + .scan((previous: nil as String?, current: nil as String?)) { tuple, newValue in + (previous: tuple.current, current: newValue) + } + .dropFirst() + .sink { [weak self] (previous, current) in + guard let self else { return } + if previous != current && current?.isEmpty == false { + store.send(.cancelSearch) + store.send(.resetArchives) + manualTriggerPullToRefresh() + } + } + .store(in: &cancellables) + + store.publisher.searchSort + .scan((previous: nil as String?, current: nil as String?)) { tuple, newValue in + (previous: tuple.current, current: newValue) + } + .dropFirst() + .sink { [weak self] (previous, current) in + guard let self else { return } + if previous != current { + store.send(.cancelSearch) + store.send(.resetArchives) + manualTriggerPullToRefresh() + } + } + .store(in: &cancellables) + + store.publisher.searchSortOrder + .scan((previous: nil as String?, current: nil as String?)) { tuple, newValue in + (previous: tuple.current, current: newValue) + } + .dropFirst() + .sink { [weak self] (previous, current) in + guard let self else { return } + if previous != current { + store.send(.cancelSearch) + store.send(.resetArchives) + manualTriggerPullToRefresh() + } + } + .store(in: &cancellables) + + store.publisher[dynamicMember: \ArchiveListFeature.State.filter] + .scan((previous: nil as SearchFilter?, current: nil as SearchFilter?)) { tuple, newValue in + (previous: tuple.current, current: newValue as SearchFilter?) + } + .dropFirst() + .sink { [weak self] (previous, current) in + guard let self else { return } + guard current?.filter?.isEmpty == false else { return } + if previous?.filter != current?.filter { + store.send(.cancelSearch) + store.send(.resetArchives) + manualTriggerPullToRefresh() + } + } + .store(in: &cancellables) + } + // swiftlint:enable function_body_length + + override func viewDidLoad() { + super.viewDidLoad() + + setupLayout() + setupRefresh() + setupCollectionView() + setupCell() + setupObserve() + + collectionView.delegate = self + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if store.lanraragiUrl.isEmpty == false && store.archives.isEmpty + && store.loadOnAppear { + manualTriggerPullToRefresh() + } else if !store.archivesToDisplay.isEmpty { + store.send(.refreshDisplayArchives) + } + } + + @objc + private func didPullToRefresh(_ sender: Any) { + Task { + await store.send(.load(true)).finish() + refreshControl.endRefreshing() + } + } + + private func manualTriggerPullToRefresh() { + guard collectionView.refreshControl?.isRefreshing == false else { return } + collectionView.refreshControl?.beginRefreshing() + let offsetPoint = CGPoint.init( + x: 0, y: -refreshControl.frame.size.height) + collectionView.setContentOffset(offsetPoint, animated: true) + collectionView.refreshControl?.sendActions(for: .valueChanged) + } + + enum Section { + case main + } +} + +extension UIArchiveListViewController: UICollectionViewDelegateFlowLayout { + override func collectionView( + _ collectionView: UICollectionView, + willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath + ) { + if indexPath.item == collectionView.numberOfItems(inSection: 0) - 1 { + if store.loading == false && store.archives.count < store.total { + Task { + self.isLoading = true + collectionView.collectionViewLayout.invalidateLayout() + await store.send( + .appendArchives(String(store.archives.count)) + ).finish() + self.isLoading = false + collectionView.collectionViewLayout.invalidateLayout() + } + } + } + } + + override func collectionView( + _ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath + ) { + guard let selectedItemStore = dataSource.itemIdentifier(for: indexPath) + else { return } + let readerStore = Store( + initialState: ArchiveReaderFeature.State.init( + archive: selectedItemStore.$archive) + ) { + ArchiveReaderFeature() + } + // traitCollection.push(value: AppFeature.Path.State.reader(ArchiveReaderFeature.State.init(archive: selectedItemStore.$archive))) + navigationController?.pushViewController( + UIArchiveReaderController(store: readerStore), animated: true) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + referenceSizeForFooterInSection section: Int + ) -> CGSize { + return isLoading + ? CGSize(width: collectionView.bounds.width, height: 80) : .zero + } +} + +class LoadingReusableView: UICollectionReusableView { + static let reuseIdentifier = "LoadingReusableView" + + let activityIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.hidesWhenStopped = true + return indicator + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + addSubview(activityIndicator) + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + activityIndicator.centerXAnchor.constraint(equalTo: centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func startAnimation() { + activityIndicator.startAnimating() + } + + func stopAnimation() { + activityIndicator.stopAnimating() + } +} diff --git a/LANreader/ContentView.swift b/LANreader/ContentView.swift index bb7465d..5fbbce2 100644 --- a/LANreader/ContentView.swift +++ b/LANreader/ContentView.swift @@ -4,11 +4,11 @@ import SwiftUI import NotificationBannerSwift import Logging -@Reducer struct AppFeature { +@Reducer public struct AppFeature { private let logger = Logger(label: "AppFeature") @ObservableState - struct State: Equatable { + public struct State: Equatable { var path = StackState() @Presents var destination: Destination.State? @@ -26,7 +26,7 @@ import Logging var settings = SettingsFeature.State() } - enum Action: BindableAction { + public enum Action: BindableAction { case path(StackAction) case destination(PresentationAction) @@ -47,7 +47,7 @@ import Logging @Dependency(\.lanraragiService) var service @Dependency(\.appDatabase) var database - var body: some Reducer { + public var body: some Reducer { BindingReducer() Scope(state: \.library, action: \.library) { @@ -100,10 +100,10 @@ import Logging await send(.setErrorMessage(error.localizedDescription)) } case let .path(.element(id: id, action: .details(.deleteSuccess))): - guard case .details = state.path[id: id] - else { return .none } - let penultimateId = state.path.ids.dropLast().last - state.path.pop(from: penultimateId!) +// guard case .details = state.path[id: id] +// else { return .none } +// let penultimateId = state.path.ids.dropLast().last +// state.path.pop(from: penultimateId!) return .none case let .setErrorMessage(message): state.errorMessage = message @@ -120,7 +120,7 @@ import Logging } @Reducer(state: .equatable) - enum Path { + public enum Path { case reader(ArchiveReaderFeature) case details(ArchiveDetailsFeature) case categoryArchiveList(CategoryArchiveListFeature) @@ -130,7 +130,7 @@ import Logging } @Reducer(state: .equatable) - enum Destination { + public enum Destination { case login(LANraragiConfigFeature) case lockScreen(LockScreenFeature) } @@ -206,26 +206,27 @@ struct ContentView: View { } var libraryView: some View { - NavigationStack( - path: $store.scope(state: \.path, action: \.path) - ) { - LibraryListV2(store: store.scope(state: \.library, action: \.library)) - } destination: { store in - switch store.case { - case let .reader(store): - ArchiveReader(store: store) - case let .details(store): - ArchiveDetailsV2(store: store) - case let .categoryArchiveList(store): - CategoryArchiveListV2(store: store) - case let .search(store): - SearchViewV2(store: store) - case let .random(store): - RandomView(store: store) - case let .cache(store): - CacheView(store: store) - } - } +// NavigationStack( +// path: $store.scope(state: \.path, action: \.path) +// ) { +// UILibraryList(store: store.scope(state: \.library, action: \.library)) +// } destination: { store in +// switch store.case { +// case let .reader(store): +// ArchiveReader(store: store) +// case let .details(store): +// ArchiveDetailsV2(store: store) +// case let .categoryArchiveList(store): +// CategoryArchiveListV2(store: store) +// case let .search(store): +// SearchViewV2(store: store) +// case let .random(store): +// RandomView(store: store) +// case let .cache(store): +// CacheView(store: store) +// } +// } + UILibraryList(store: store.scope(state: \.library, action: \.library)) .tabItem { Image(systemName: "books.vertical") Text("library") @@ -234,26 +235,27 @@ struct ContentView: View { } var categoryView: some View { - NavigationStack( - path: $store.scope(state: \.path, action: \.path) - ) { - CategoryListV2(store: store.scope(state: \.category, action: \.category)) - } destination: { store in - switch store.case { - case let .reader(store): - ArchiveReader(store: store) - case let .details(store): - ArchiveDetailsV2(store: store) - case let .categoryArchiveList(store): - CategoryArchiveListV2(store: store) - case let .search(store): - SearchViewV2(store: store) - case let .random(store): - RandomView(store: store) - case let .cache(store): - CacheView(store: store) - } - } +// NavigationStack( +// path: $store.scope(state: \.path, action: \.path) +// ) { +// CategoryListV2(store: store.scope(state: \.category, action: \.category)) +// } destination: { store in +// switch store.case { +// case let .reader(store): +// ArchiveReader(store: store) +// case let .details(store): +// ArchiveDetailsV2(store: store, onDelete: { }) +// case let .categoryArchiveList(store): +// CategoryArchiveListV2(store: store) +// case let .search(store): +// SearchViewV2(store: store) +// case let .random(store): +// RandomView(store: store) +// case let .cache(store): +// CacheView(store: store) +// } +// } + UICategoryList(store: store.scope(state: \.category, action: \.category)) .tabItem { Image(systemName: "folder") Text("category") @@ -262,26 +264,27 @@ struct ContentView: View { } var searchView: some View { - NavigationStack( - path: $store.scope(state: \.path, action: \.path) - ) { - SearchViewV2(store: store.scope(state: \.search, action: \.search)) - } destination: { store in - switch store.case { - case let .reader(store): - ArchiveReader(store: store) - case let .details(store): - ArchiveDetailsV2(store: store) - case let .categoryArchiveList(store): - CategoryArchiveListV2(store: store) - case let .search(store): - SearchViewV2(store: store) - case let .random(store): - RandomView(store: store) - case let .cache(store): - CacheView(store: store) - } - } +// NavigationStack( +// path: $store.scope(state: \.path, action: \.path) +// ) { +// SearchViewV2(store: store.scope(state: \.search, action: \.search)) +// } destination: { store in +// switch store.case { +// case let .reader(store): +// ArchiveReader(store: store) +// case let .details(store): +// ArchiveDetailsV2(store: store, onDelete: { }) +// case let .categoryArchiveList(store): +// CategoryArchiveListV2(store: store) +// case let .search(store): +// SearchViewV2(store: store) +// case let .random(store): +// RandomView(store: store) +// case let .cache(store): +// CacheView(store: store) +// } +// } + UISearchView(store: store.scope(state: \.search, action: \.search)) .tabItem { Image(systemName: "magnifyingglass") Text("search") @@ -317,3 +320,18 @@ struct Covers: ViewModifier { } } } + +@nonobjc extension UIViewController { + func add(_ child: UIViewController) { + addChild(child) + child.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(child.view) + child.didMove(toParent: self) + } + + func remove() { + willMove(toParent: nil) + view.removeFromSuperview() + removeFromParent() + } +} diff --git a/LANreader/Database/Record.swift b/LANreader/Database/Record.swift index 2b82ede..0ac9431 100644 --- a/LANreader/Database/Record.swift +++ b/LANreader/Database/Record.swift @@ -49,8 +49,8 @@ struct Category: Identifiable { var lastUpdate: Date } -struct DownloadJob: Identifiable, Equatable { - var id: Int +public struct DownloadJob: Identifiable, Equatable { + public var id: Int var url: String var title: String var isActive: Bool diff --git a/LANreader/LANraragiConfigView.swift b/LANreader/LANraragiConfigView.swift index b5128cf..1185b3c 100644 --- a/LANreader/LANraragiConfigView.swift +++ b/LANreader/LANraragiConfigView.swift @@ -4,11 +4,11 @@ import SwiftUI import NotificationBannerSwift import Logging -@Reducer struct LANraragiConfigFeature { +@Reducer public struct LANraragiConfigFeature { private let logger = Logger(label: "LANraragiConfigFeature") @ObservableState - struct State: Equatable { + public struct State: Equatable { @Shared(.appStorage(SettingsKey.serverProgress)) var serverProgress = false @Shared(.appStorage(SettingsKey.lanraragiUrl)) var url = "" @Shared(.appStorage(SettingsKey.lanraragiApiKey)) var apiKey = "" @@ -20,7 +20,7 @@ import Logging var errorMessage = "" } - enum Action: Equatable, BindableAction { + public enum Action: Equatable, BindableAction { case binding(BindingAction) case verifyServer case saveComplate @@ -35,7 +35,7 @@ import Logging @Dependency(\.lanraragiService) var lanraragiService @Dependency(\.dismiss) var dismiss - var body: some Reducer { + public var body: some Reducer { BindingReducer() Reduce { state, action in switch action { @@ -45,7 +45,7 @@ import Logging let serverInfo = try await lanraragiService.verifyClient( url: state.formUrl, apiKey: state.formKey ).value - if serverInfo.serverTracksProgress == "1" { + if serverInfo.serverTracksProgress { await send(.setServerProgress(true)) } else { await send(.setServerProgress(false)) diff --git a/LANreader/Library/CacheView.swift b/LANreader/Library/CacheView.swift index ea79493..b7b3887 100644 --- a/LANreader/Library/CacheView.swift +++ b/LANreader/Library/CacheView.swift @@ -2,16 +2,16 @@ import SwiftUI import ComposableArchitecture import NotificationBannerSwift -@Reducer struct CacheFeature { +@Reducer public struct CacheFeature { @ObservableState - struct State: Equatable { + public struct State: Equatable { var archives: IdentifiedArrayOf = [] var downloading: [String: PageProgress] = [:] var showLoading: Bool = false var errorMessage: String = "" } - enum Action: Equatable { + public enum Action: Equatable { case grid(IdentifiedActionOf) case load case refreshProgress @@ -24,7 +24,7 @@ import NotificationBannerSwift @Dependency(\.appDatabase) var database @Dependency(\.continuousClock) var clock - var body: some ReducerOf { + public var body: some ReducerOf { Reduce { state, action in switch action { case .load: diff --git a/LANreader/Library/LibraryListV2.swift b/LANreader/Library/LibraryListV2.swift index bc06a1f..0af1c3a 100644 --- a/LANreader/Library/LibraryListV2.swift +++ b/LANreader/Library/LibraryListV2.swift @@ -2,18 +2,18 @@ import ComposableArchitecture import Logging import SwiftUI -@Reducer struct LibraryFeature { +@Reducer public struct LibraryFeature { private let logger = Logger(label: "LibraryFeature") @ObservableState - struct State: Equatable { + public struct State: Equatable { var archiveList = ArchiveListFeature.State( filter: SearchFilter(category: nil, filter: nil), currentTab: .library ) } - enum Action: Equatable, BindableAction { + public enum Action: Equatable, BindableAction { case binding(BindingAction) case archiveList(ArchiveListFeature.Action) @@ -23,7 +23,7 @@ import SwiftUI @Dependency(\.lanraragiService) var service @Dependency(\.appDatabase) var database - var body: some ReducerOf { + public var body: some ReducerOf { BindingReducer() Scope(state: \.archiveList, action: \.archiveList) { diff --git a/LANreader/Library/RandomView.swift b/LANreader/Library/RandomView.swift index 5c6d925..6002f05 100644 --- a/LANreader/Library/RandomView.swift +++ b/LANreader/Library/RandomView.swift @@ -3,11 +3,11 @@ import SwiftUI import Logging import NotificationBannerSwift -@Reducer struct RandomFeature { +@Reducer public struct RandomFeature { private let logger = Logger(label: "RandomFeature") @ObservableState - struct State: Equatable { + public struct State: Equatable { var archives: IdentifiedArrayOf = [] @Shared(.archive) var archiveItems: IdentifiedArrayOf = [] @@ -17,7 +17,7 @@ import NotificationBannerSwift var errorMessage = "" } - enum Action: Equatable { + public enum Action: Equatable { case grid(IdentifiedActionOf) case load(Bool) case populateArchives([ArchiveItem]) @@ -27,7 +27,7 @@ import NotificationBannerSwift @Dependency(\.lanraragiService) var service - var body: some ReducerOf { + public var body: some ReducerOf { Reduce { state, action in switch action { case let .load(showLoading): diff --git a/LANreader/Library/UILibraryList.swift b/LANreader/Library/UILibraryList.swift new file mode 100644 index 0000000..35433ef --- /dev/null +++ b/LANreader/Library/UILibraryList.swift @@ -0,0 +1,52 @@ +import ComposableArchitecture +import SwiftUI +import UIKit + +public struct UILibraryList: UIViewControllerRepresentable { + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public func makeUIViewController(context: Context) -> UIViewController { + UINavigationController(rootViewController: UILibraryListViewController(store: store)) + } + + public func updateUIViewController( + _ uiViewController: UIViewController, + context: Context + ) { + // Nothing to do + } +} + +class UILibraryListViewController: UIViewController { + let store: StoreOf + + init(store: StoreOf) { + self.store = store + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.title = String(localized: "library") + + let archiveListView = UIArchiveListViewController( + store: store.scope(state: \.archiveList, action: \.archiveList) + ) + add(archiveListView) + NSLayoutConstraint.activate([ + archiveListView.view.topAnchor.constraint(equalTo: view.topAnchor), + archiveListView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + archiveListView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + archiveListView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } +} diff --git a/LANreader/Models/AppModels.swift b/LANreader/Models/AppModels.swift index 58a99bf..0517547 100644 --- a/LANreader/Models/AppModels.swift +++ b/LANreader/Models/AppModels.swift @@ -5,7 +5,7 @@ import Foundation import ComposableArchitecture -struct SearchFilter: Equatable { +public struct SearchFilter: Equatable { let category: String? let filter: String? } @@ -101,6 +101,7 @@ struct SettingsKey { static let hideRead = "settings.view.hideRead" static let lastTagRefresh = "lastTagRefresh" + static let tabBarHidden = "tab.bar.hidden" } extension Double { diff --git a/LANreader/Models/LANraragiResponse.swift b/LANreader/Models/LANraragiResponse.swift index e325aca..3d25180 100644 --- a/LANreader/Models/LANraragiResponse.swift +++ b/LANreader/Models/LANraragiResponse.swift @@ -70,13 +70,13 @@ struct JobResult: Decodable { } struct ServerInfo: Decodable { - let archivesPerPage: String - let debugMode: String - let hasPassword: String + let archivesPerPage: Int + let debugMode: Bool + let hasPassword: Bool let motd: String let name: String - let nofunMode: String - let serverTracksProgress: String + let nofunMode: Bool + let serverTracksProgress: Bool let version: String let versionName: String } diff --git a/LANreader/Models/ViewModels.swift b/LANreader/Models/ViewModels.swift index 0227cc8..c37af9f 100644 --- a/LANreader/Models/ViewModels.swift +++ b/LANreader/Models/ViewModels.swift @@ -3,8 +3,8 @@ import Foundation import SwiftUI -struct ArchiveItem: Identifiable, Equatable, Hashable { - let id: String +public struct ArchiveItem: Identifiable, Equatable, Hashable { + public let id: String var name: String let normalizedName: String let `extension`: String @@ -16,8 +16,8 @@ struct ArchiveItem: Identifiable, Equatable, Hashable { let dateAdded: Int? } -struct CategoryItem: Identifiable, Equatable, Hashable { - let id: String +public struct CategoryItem: Identifiable, Equatable, Hashable { + public let id: String let name: String var archives: [String] let search: String diff --git a/LANreader/Page/ArchiveDetailsV2.swift b/LANreader/Page/ArchiveDetailsV2.swift index 9d48467..3681e94 100644 --- a/LANreader/Page/ArchiveDetailsV2.swift +++ b/LANreader/Page/ArchiveDetailsV2.swift @@ -3,12 +3,12 @@ import SwiftUI import Logging import NotificationBannerSwift -@Reducer struct ArchiveDetailsFeature { +@Reducer public struct ArchiveDetailsFeature { private let logger = Logger(label: "ArchiveDetailsFeature") @ObservableState - struct State: Equatable { - @Presents var alert: AlertState? + public struct State: Equatable { +// @Presents var alert: AlertState? @Shared(.archive) var archiveItems: IdentifiedArrayOf = [] @Shared(.category) var categoryItems: IdentifiedArrayOf = [] @@ -22,6 +22,7 @@ import NotificationBannerSwift var successMessage = "" var loading = false let cached: Bool + var showAlert: Bool = false init(archive: Shared, cached: Bool = false) { self._archive = archive @@ -31,9 +32,9 @@ import NotificationBannerSwift } } - enum Action: Equatable, BindableAction { + public enum Action: Equatable, BindableAction { case binding(BindingAction) - case alert(PresentationAction) +// case alert(PresentationAction) case loadLocalFields case updateArchiveMetadata @@ -44,18 +45,19 @@ import NotificationBannerSwift case updateLocalCategoryItems(String, String, Bool) case setErrorMessage(String) case setSuccessMessage(String) + case confirmDelete case deleteButtonTapped case deleteSuccess - enum Alert { - case confirmDelete - } +// public enum Alert { +// case confirmDelete +// } } @Dependency(\.lanraragiService) var service @Dependency(\.appDatabase) var database - var body: some ReducerOf { + public var body: some ReducerOf { BindingReducer() Reduce { state, action in @@ -80,18 +82,19 @@ import NotificationBannerSwift state.tags = state.archive.tags return .none case .deleteButtonTapped: - state.alert = AlertState { - TextState("archive.delete.confirm") - } actions: { - ButtonState(role: .destructive, action: .confirmDelete) { - TextState("delete") - } - ButtonState(role: .cancel) { - TextState("cancel") - } - } + state.showAlert = true +// state.alert = AlertState { +// TextState("archive.delete.confirm") +// } actions: { +// ButtonState(role: .destructive, action: .confirmDelete) { +// TextState("delete") +// } +// ButtonState(role: .cancel) { +// TextState("cancel") +// } +// } return .none - case .alert(.presented(.confirmDelete)): + case .confirmDelete: state.loading = true return .run { [id = state.archive.id] send in let response = try await service.deleteArchive(id: id).value @@ -104,6 +107,19 @@ import NotificationBannerSwift logger.error("failed to delete archive, id=\(id) \(error)") await send(.setErrorMessage(error.localizedDescription)) } +// case .alert(.presented(.confirmDelete)): +// state.loading = true +// return .run { [id = state.archive.id] send in +// let response = try await service.deleteArchive(id: id).value +// if response.success == 1 { +// await send(.deleteSuccess) +// } else { +// await send(.setErrorMessage(String(localized: "error.archive.delete"))) +// } +// } catch: { [id = state.archive.id] error, send in +// logger.error("failed to delete archive, id=\(id) \(error)") +// await send(.setErrorMessage(error.localizedDescription)) +// } case .loadCategory: return .run { send in let categories = try await service.retrieveCategories().value @@ -182,8 +198,8 @@ import NotificationBannerSwift case let .setSuccessMessage(message): state.successMessage = message return .none - case .alert: - return .none +// case .alert: +// return .none case .deleteSuccess: state.archiveItems.remove(id: state.archive.id) return .none @@ -191,7 +207,7 @@ import NotificationBannerSwift return .none } } - .ifLet(\.$alert, action: \.alert) +// .ifLet(\.$alert, action: \.alert) } } @@ -199,7 +215,11 @@ struct ArchiveDetailsV2: View { private static let sourceTag = "source" private static let dateTag = "date_added" + @Environment(\.openURL) var openURL + @Bindable var store: StoreOf + let onDelete: () -> Void + let onTagNavigation: (StoreOf) -> Void var body: some View { ScrollView { @@ -272,9 +292,18 @@ struct ArchiveDetailsV2: View { } } .environment(\.editMode, $store.editMode) - .alert( - $store.scope(state: \.alert, action: \.alert) - ) +// .alert( +// $store.scope(state: \.alert, action: \.alert) +// ) + .alert("archive.delete.confirm", isPresented: $store.showAlert) { + Button("cancel", role: .cancel) { } + Button("delete", role: .destructive) { + Task { + await store.send(.confirmDelete).finish() + } + onDelete() + } + } .onChange(of: store.editMode) { oldMode, newMode in if oldMode == .active && newMode == .inactive { store.send(.updateArchiveMetadata) @@ -354,12 +383,15 @@ struct ArchiveDetailsV2: View { let tagValue = tagPair.count == 2 ? tagPair[1].trimmingCharacters(in: .whitespacesAndNewlines) : "" if tagName == ArchiveDetailsV2.sourceTag { let urlString = tagValue.hasPrefix("http") ? tagValue : "https://\(tagValue)" - return AnyView( - Link(destination: URL(string: urlString)!) { + return +// Link(destination: URL(string: urlString)!) { Text(tag) .lineLimit(1) - } - ) + .onTapGesture { + openURL(URL(string: urlString)!) + } +// } + } let processedTag: String if tagName == ArchiveDetailsV2.dateTag { @@ -369,21 +401,36 @@ struct ArchiveDetailsV2: View { processedTag = tag } let normalizedTag = String(tag.trimmingCharacters(in: .whitespacesAndNewlines)) - return AnyView( - NavigationLink( - state: AppFeature.Path.State.search( - SearchFeature.State.init( - keyword: normalizedTag, archiveList: ArchiveListFeature.State( - filter: SearchFilter(category: nil, filter: normalizedTag), - loadOnAppear: true, - currentTab: .search - ) + return Text(processedTag) + .lineLimit(1) + .onTapGesture { + let searchStore = Store(initialState: SearchFeature.State.init( + keyword: normalizedTag, archiveList: ArchiveListFeature.State( + filter: SearchFilter(category: nil, filter: normalizedTag), + loadOnAppear: true, + currentTab: .search ) - ) - ) { - Text(processedTag) - .lineLimit(1) + )) { + SearchFeature() + } + onTagNavigation(searchStore) } - ) + +// return AnyView( +// NavigationLink( +// state: AppFeature.Path.State.search( +// SearchFeature.State.init( +// keyword: normalizedTag, archiveList: ArchiveListFeature.State( +// filter: SearchFilter(category: nil, filter: normalizedTag), +// loadOnAppear: true, +// currentTab: .search +// ) +// ) +// ) +// ) { +// Text(processedTag) +// .lineLimit(1) +// } +// ) } } diff --git a/LANreader/Page/ArchiveReader.swift b/LANreader/Page/ArchiveReader.swift index 064e6a6..a15ff63 100644 --- a/LANreader/Page/ArchiveReader.swift +++ b/LANreader/Page/ArchiveReader.swift @@ -5,11 +5,11 @@ import Combine import NotificationBannerSwift import OrderedCollections -@Reducer struct ArchiveReaderFeature { +@Reducer public struct ArchiveReaderFeature { private let logger = Logger(label: "ArchiveReaderFeature") @ObservableState - struct State: Equatable { + public struct State: Equatable { @Presents var alert: AlertState? @SharedReader(.appStorage(SettingsKey.tapLeftKey)) var tapLeft = PageControl.next.rawValue @@ -24,8 +24,9 @@ import OrderedCollections @SharedReader(.appStorage(SettingsKey.doublePageLayout)) var doublePageLayout = false @Shared var archive: ArchiveItem - var indexString: String? +// var indexString: String? var sliderIndex: Double = 0 + var jumpIndex: Int = 0 var pages: IdentifiedArrayOf = [] var fromStart = false var extracting = false @@ -40,12 +41,9 @@ import OrderedCollections var cached = false var inCache = false - var fallbackIndexString: String { - indexString ?? "" - } - var currentIndex: Int? { - pages.index(id: indexString ?? "") - } +// var currentIndex: Int? { +// pages.index(id: indexString ?? "") +// } init(archive: Shared, fromStart: Bool = false, cached: Bool = false) { self._archive = archive @@ -54,7 +52,7 @@ import OrderedCollections } } - enum Action: Equatable, BindableAction { + public enum Action: Equatable, BindableAction { case binding(BindingAction) case alert(PresentationAction) @@ -66,14 +64,14 @@ import OrderedCollections case loadProgress case finishExtracting([String]) case toggleControlUi(Bool?) - case preload(Int) - case setIndexString(String) +// case setIndexString(String) + case setJumpIndex(Int) case setSliderIndex(Double) case updateProgress(Int) case setIsNew(Bool) case setThumbnail case finishThumbnailLoading - case tapAction(String) +// case tapAction(String) case setError(String) case setSuccess(String) case downloadPages @@ -81,7 +79,7 @@ import OrderedCollections case removeCache case loadCached - enum Alert { + public enum Alert { case confirmDelete } } @@ -91,12 +89,12 @@ import OrderedCollections @Dependency(\.appDatabase) var database @Dependency(\.dismiss) var dismiss - enum CancelId { + public enum CancelId { case updateProgress case autoPage } - var body: some ReducerOf { + public var body: some ReducerOf { BindingReducer() Scope(state: \.autoPage, action: \.autoPage) { @@ -127,7 +125,7 @@ import OrderedCollections } state.pages.append(contentsOf: pageState) state.sliderIndex = 0.0 - state.indexString = state.pages[0].id +// state.indexString = state.pages[0].id state.controlUiHidden = true state.extracting = false return .none @@ -159,7 +157,7 @@ import OrderedCollections case let .finishExtracting(pages): if !pages.isEmpty { let pageState = pages.enumerated().map { (index, page) in - let normalizedPage = String(page.dropFirst(2)) + let normalizedPage = String(page.dropFirst(1)) return PageFeature.State( archiveId: state.archive.id, pageId: normalizedPage, pageNumber: index + 1 ) @@ -167,8 +165,10 @@ import OrderedCollections state.pages.append(contentsOf: pageState) let progress = state.archive.progress > 0 ? state.archive.progress - 1 : 0 let pageIndexToShow = state.fromStart ? 0 : progress +// print("inside store \(pageIndexToShow)") state.sliderIndex = Double(pageIndexToShow) - state.indexString = state.pages[pageIndexToShow].id + state.jumpIndex = pageIndexToShow +// state.indexString = state.pages[pageIndexToShow].id state.controlUiHidden = true } state.extracting = false @@ -176,7 +176,7 @@ import OrderedCollections case .loadProgress: let progress = state.archive.progress > 0 ? state.archive.progress - 1 : 0 state.sliderIndex = Double(progress) - state.indexString = state.pages[progress].id +// state.indexString = state.pages[progress].id state.controlUiHidden = true return .none case let .toggleControlUi(show): @@ -186,40 +186,11 @@ import OrderedCollections state.controlUiHidden.toggle() } return .none - case let .preload(index): - return .run(priority: .utility) { [state] send in - if state.doublePageLayout && - state.readDirection != ReadDirection.upDown.rawValue && - !state.fallbackReader { - if index - 1 > 0 { - let previous2PageId = state.pages[index-2].id - await send(.page(.element(id: previous2PageId, action: .load(false)))) - } - if index - 2 > 0 { - let previous3PageId = state.pages[index-3].id - await send(.page(.element(id: previous3PageId, action: .load(false)))) - } - if index + 2 < state.pages.count { - let next2PageId = state.pages[index+2].id - await send(.page(.element(id: next2PageId, action: .load(false)))) - } - if index + 3 < state.pages.count { - let next3PageId = state.pages[index+3].id - await send(.page(.element(id: next3PageId, action: .load(false)))) - } - } else { - if index > 0 { - let previousPageId = state.pages[index-1].id - await send(.page(.element(id: previousPageId, action: .load(false)))) - } - if index + 1 < state.pages.count { - let nextPageId = state.pages[index+1].id - await send(.page(.element(id: nextPageId, action: .load(false)))) - } - } - } - case let .setIndexString(indexString): - state.indexString = indexString +// case let .setIndexString(indexString): +// state.indexString = indexString +// return .none + case let .setJumpIndex(jumpIndex): + state.jumpIndex = jumpIndex return .none case let .setSliderIndex(index): state.sliderIndex = index @@ -248,7 +219,7 @@ import OrderedCollections return .none case .setThumbnail: state.settingThumbnail = true - guard let pageNumber = state.pages[id: state.indexString ?? ""]?.pageNumber else {return .none } + let pageNumber = state.pages[state.sliderIndex.int].pageNumber return .run { [id = state.archive.id] send in _ = try await service.updateArchiveThumbnail(id: id, page: pageNumber).value let thumbnailUrl = try await service.retrieveArchiveThumbnail(id: id) @@ -271,49 +242,8 @@ import OrderedCollections state.settingThumbnail = false state.archive.refresh = true return .none - case let .tapAction(action): - switch action { - case PageControl.next.rawValue: - if let pageIndex = state.currentIndex { - if state.doublePageLayout && - state.readDirection != ReadDirection.upDown.rawValue && - !state.fallbackReader { - if pageIndex < state.pages.count - 2 { - state.indexString = state.pages[pageIndex + 2].id - } else if pageIndex < state.pages.count - 1 { - state.indexString = state.pages[pageIndex + 1].id - } - } else { - if pageIndex < state.pages.count - 1 { - state.indexString = state.pages[pageIndex + 1].id - } - } - } - case PageControl.previous.rawValue: - if let pageIndex = state.currentIndex { - if state.doublePageLayout && - state.readDirection != ReadDirection.upDown.rawValue && - !state.fallbackReader { - if pageIndex > 1 { - state.indexString = state.pages[pageIndex - 2].id - } else if pageIndex > 0 { - state.indexString = state.pages[pageIndex - 1].id - } - } else { - if pageIndex > 0 { - state.indexString = state.pages[pageIndex - 1].id - } - } - } - case PageControl.navigation.rawValue: - state.controlUiHidden.toggle() - state.startAutoPage = false - return .cancel(id: CancelId.autoPage) - default: - // This should not happen - break - } - return .none +// case let .tapAction(action): +// return .none case let .setSuccess(message): state.successMessage = message return .none @@ -441,9 +371,6 @@ struct ArchiveReader: View { ZStack { if store.readDirection == ReadDirection.upDown.rawValue { vReader(store: store, geometry: geometry) - } else if store.fallbackReader { - hReaderFallback(store: store, geometry: geometry) - .environment(\.layoutDirection, flip ? .rightToLeft : .leftToRight) } else { hReader(store: store, geometry: geometry) .environment(\.layoutDirection, flip ? .rightToLeft : .leftToRight) @@ -460,40 +387,28 @@ struct ArchiveReader: View { .focusable() .focused($isFocused) .focusEffectDisabled() - .onKeyPress(keys: [.leftArrow, .rightArrow]) { press in - if store.readDirection == ReadDirection.leftRight.rawValue { - if press.key == .leftArrow { - store.send(.tapAction(PageControl.previous.rawValue), animation: .linear) - } else if press.key == .rightArrow { - store.send(.tapAction(PageControl.next.rawValue), animation: .linear) - } - } else if store.readDirection == ReadDirection.rightLeft.rawValue { - if press.key == .leftArrow { - store.send(.tapAction(PageControl.next.rawValue), animation: .linear) - } else if press.key == .rightArrow { - store.send(.tapAction(PageControl.previous.rawValue), animation: .linear) - } - } - return .handled - } +// .onKeyPress(keys: [.leftArrow, .rightArrow]) { press in +// if store.readDirection == ReadDirection.leftRight.rawValue { +// if press.key == .leftArrow { +// store.send(.tapAction(PageControl.previous.rawValue), animation: .linear) +// } else if press.key == .rightArrow { +// store.send(.tapAction(PageControl.next.rawValue), animation: .linear) +// } +// } else if store.readDirection == ReadDirection.rightLeft.rawValue { +// if press.key == .leftArrow { +// store.send(.tapAction(PageControl.next.rawValue), animation: .linear) +// } else if press.key == .rightArrow { +// store.send(.tapAction(PageControl.previous.rawValue), animation: .linear) +// } +// } +// return .handled +// } .onAppear { isFocused = true } - .toolbar { - ToolbarItem(placement: .primaryAction) { - NavigationLink( - state: AppFeature.Path.State.details( - ArchiveDetailsFeature.State.init(archive: store.$archive, cached: store.cached) - ) - ) { - Image(systemName: "info.circle") - } - } - } .alert( $store.scope(state: \.alert, action: \.alert) ) - .toolbar(store.controlUiHidden ? .hidden : .visible, for: .navigationBar) .navigationBarTitle(store.archive.name) .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .tabBar) @@ -519,15 +434,6 @@ struct ArchiveReader: View { banner.show() } } - .onChange(of: store.indexString) { _, newValue in - if let id = newValue { - let index = store.pages.index(id: id) ?? 0 - let pageNumber = store.pages[id: id]?.pageNumber ?? 1 - store.send(.preload(index)) - store.send(.setSliderIndex(Double(index))) - store.send(.updateProgress(pageNumber)) - } - } .onChange(of: store.errorMessage) { if !store.errorMessage.isEmpty { let banner = NotificationBanner( @@ -551,11 +457,11 @@ struct ArchiveReader: View { store.send(.setSuccess("")) } } - .onChange(of: store.autoDate) { - if store.startAutoPage { - store.send(.tapAction(PageControl.next.rawValue), animation: .linear) - } - } +// .onChange(of: store.autoDate) { +// if store.startAutoPage { +// store.send(.tapAction(PageControl.next.rawValue), animation: .linear) +// } +// } } @MainActor @@ -563,25 +469,7 @@ struct ArchiveReader: View { store: StoreOf, geometry: GeometryProxy ) -> some View { - ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach( - store.scope( - state: \.pages, - action: \.page - ), - id: \.state.id - ) { pageStore in - PageImageV2(store: pageStore, geometrySize: geometry.size) - .frame(width: geometry.size.width) - } - } - .scrollTargetLayout() - } - .scrollPosition(id: $store.indexString) - .onTapGesture { - store.send(.tapAction(PageControl.navigation.rawValue)) - } + UIPageCollection(store: store, size: geometry.size) } @MainActor @@ -589,62 +477,7 @@ struct ArchiveReader: View { store: StoreOf, geometry: GeometryProxy ) -> some View { - ScrollView(.horizontal) { - LazyHStack(spacing: 0) { - ForEach( - store.scope( - state: \.pages, - action: \.page - ), - id: \.state.id - ) { pageStore in - PageImageV2(store: pageStore, geometrySize: geometry.size) - .frame(width: store.doublePageLayout ? geometry.size.width / 2 : geometry.size.width) - } - } - .scrollTargetLayout() - } - .scrollTargetBehavior(.viewAligned(limitBehavior: .always)) - .scrollPosition(id: $store.indexString) - .onTapGesture { location in - if location.x < geometry.size.width / 3 { - store.send(.tapAction(store.tapLeft), animation: .linear) - } else if location.x > geometry.size.width / 3 * 2 { - store.send(.tapAction(store.tapRight), animation: .linear) - } else { - store.send(.tapAction(store.tapMiddle), animation: .linear) - } - } - } - - @MainActor - private func hReaderFallback( - store: StoreOf, - geometry: GeometryProxy - ) -> some View { - TabView(selection: $store.fallbackIndexString.sending(\.setIndexString)) { - ForEach( - store.scope( - state: \.pages, - action: \.page - ), - id: \.state.id - ) { pageStore in - PageImageV2(store: pageStore, geometrySize: geometry.size) - .frame(width: geometry.size.width) - .tag(pageStore.state.id) - } - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .onTapGesture { location in - if location.x < geometry.size.width / 3 { - store.send(.tapAction(store.tapLeft), animation: .linear) - } else if location.x > geometry.size.width / 3 * 2 { - store.send(.tapAction(store.tapRight), animation: .linear) - } else { - store.send(.tapAction(store.tapMiddle), animation: .linear) - } - } + UIPageCollection(store: store, size: geometry.size) } // swiftlint:disable function_body_length @@ -657,7 +490,8 @@ struct ArchiveReader: View { Grid { GridRow { Button(action: { - store.send(.page(.element(id: store.indexString ?? "", action: .load(true)))) + let indexString = store.pages[store.sliderIndex.int].id + store.send(.page(.element(id: indexString, action: .load(true)))) }, label: { Image(systemName: "arrow.clockwise") }) @@ -697,8 +531,7 @@ struct ArchiveReader: View { step: 1 ) { onSlider in if !onSlider { - let indexString = store.pages[store.sliderIndex.int].id - store.send(.setIndexString(indexString)) + store.send(.setJumpIndex(store.sliderIndex.int)) } } .padding(.horizontal) diff --git a/LANreader/Page/AutomaticPageConfig.swift b/LANreader/Page/AutomaticPageConfig.swift index d65b165..c427259 100644 --- a/LANreader/Page/AutomaticPageConfig.swift +++ b/LANreader/Page/AutomaticPageConfig.swift @@ -1,22 +1,22 @@ import SwiftUI import ComposableArchitecture -@Reducer struct AutomaticPageFeature { +@Reducer public struct AutomaticPageFeature { @ObservableState - struct State: Equatable { + public struct State: Equatable { @Shared(.appStorage(SettingsKey.autoPageInterval)) var autoPageInterval = 5.0 var showAutomaticPage: Bool = false } - enum Action: Equatable, BindableAction { + public enum Action: Equatable, BindableAction { case binding(BindingAction) case startAutoPage case cancelAutoPage } - var body: some Reducer { + public var body: some Reducer { BindingReducer() Reduce { _, action in diff --git a/LANreader/Page/PageImageV2.swift b/LANreader/Page/PageImageV2.swift index f2d2149..b9882f8 100644 --- a/LANreader/Page/PageImageV2.swift +++ b/LANreader/Page/PageImageV2.swift @@ -3,15 +3,14 @@ import Alamofire import SwiftUI import Logging -@Reducer struct PageFeature { +@Reducer public struct PageFeature { private let logger = Logger(label: "PageFeature") @ObservableState - struct State: Equatable, Identifiable { - @SharedReader(.appStorage(SettingsKey.showOriginal)) var showOriginal = false - @SharedReader(.appStorage(SettingsKey.fallbackReader)) var fallback = false + public struct State: Equatable, Identifiable { @SharedReader(.appStorage(SettingsKey.splitWideImage)) var splitImage = false @SharedReader(.appStorage(SettingsKey.splitPiorityLeft)) var piorityLeft = false + @SharedReader(.appStorage(SettingsKey.readDirection)) var readDirection = ReadDirection.leftRight.rawValue let pageId: String let suffix: String @@ -22,7 +21,7 @@ import Logging var pageMode: PageMode let cached: Bool - var id: String { + public var id: String { "\(pageId)-\(suffix)" } @@ -52,7 +51,7 @@ import Logging } } - enum Action: Equatable { + public enum Action: Equatable { case load(Bool) case setIsLoading(Bool) case subscribeToProgress(DownloadRequest) @@ -66,9 +65,9 @@ import Logging @Dependency(\.lanraragiService) var service @Dependency(\.imageService) var imageService - enum CancelId { case imageProgress } + public enum CancelId { case imageProgress } - var body: some ReducerOf { + public var body: some ReducerOf { Reduce { state, action in switch action { case let .subscribeToProgress(progress): @@ -96,7 +95,7 @@ import Logging if force { state.pageMode = .loading } else if state.pageMode == .loading { - if state.splitImage && !state.fallback { + if state.splitImage { if state.piorityLeft && FileManager.default.fileExists( atPath: state.pathLeft?.path(percentEncoded: false) ?? "" @@ -132,15 +131,11 @@ import Logging .value await send(.cancelSubscribeImageProgress) - if !state.showOriginal { - await send(.setProgress(2.0)) - } let splitted = imageService.resizeImage( imageUrl: imageUrl, destinationUrl: state.folder!, pageNumber: String(state.pageNumber), - split: state.splitImage && !state.fallback, - skip: state.showOriginal + split: state.splitImage ) await send(.setImage(previousPageMode, splitted)) } catch { @@ -192,10 +187,6 @@ struct PageImageV2: View { let store: StoreOf let geometrySize: CGSize - // LazyHStack not clean up memory after item load and go off screen - // Use this state to explicity release memory when page go off screen - @State var visible = false - var body: some View { // If not wrapped in ZStack, TabView will render ALL pages when initial load ZStack { @@ -214,46 +205,33 @@ struct PageImageV2: View { .frame(height: geometrySize.height) .padding(.horizontal, 20) .tint(.primary) - .task { - store.send(.load(false)) - } } else { - if visible { - let contentPath = { - switch store.pageMode { - case .left: - return store.pathLeft - case .right: - return store.pathRight - default: - return store.path - } - }() - - if let uiImage = UIImage(contentsOfFile: contentPath?.path(percentEncoded: false) ?? "") { - Image(uiImage: uiImage) - .resizable() - .aspectRatio(contentMode: .fit) - .draggableAndZoomable(contentSize: geometrySize) - } else { - Image(systemName: "rectangle.slash") - .frame(height: geometrySize.height) + let contentPath = { + switch store.pageMode { + case .left: + return store.pathLeft + case .right: + return store.pathRight + default: + return store.path } + }() + + if let uiImage = UIImage(contentsOfFile: contentPath?.path(percentEncoded: false) ?? "") { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .draggableAndZoomable(contentSize: geometrySize) } else { - Color.clear + Image(systemName: "rectangle.slash") + .frame(height: geometrySize.height) } } } - .onAppear { - visible = true - } - .onDisappear { - visible = false - } } } -enum PageMode: String { +public enum PageMode: String { case loading case left case right diff --git a/LANreader/Page/UIArchiveDetails.swift b/LANreader/Page/UIArchiveDetails.swift new file mode 100644 index 0000000..0ec04db --- /dev/null +++ b/LANreader/Page/UIArchiveDetails.swift @@ -0,0 +1,35 @@ +import UIKit +import SwiftUI +import ComposableArchitecture + +public struct UIArchiveDetails: UIViewControllerRepresentable { + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public func makeUIViewController(context: Context) -> UIViewController { + UIArchiveDetailsController(store: store) + } + + public func updateUIViewController( + _ uiViewController: UIViewController, + context: Context + ) { + // Nothing to do + } +} + +class UIArchiveDetailsController: UIViewController { + let store: StoreOf + + init(store: StoreOf) { + self.store = store + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/LANreader/Page/UIArchiveReader.swift b/LANreader/Page/UIArchiveReader.swift new file mode 100644 index 0000000..43d4965 --- /dev/null +++ b/LANreader/Page/UIArchiveReader.swift @@ -0,0 +1,87 @@ +import UIKit +import SwiftUI +import Combine +import ComposableArchitecture + +class UIArchiveReaderController: UIViewController { + private let store: StoreOf + private var hostingController: UIHostingController! + private var cancellables: Set = [] + + init(store: StoreOf) { + self.store = store + self.hostingController = UIHostingController(rootView: ArchiveReader(store: store)) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayout() { + navigationItem.title = store.archive.name + navigationItem.largeTitleDisplayMode = .inline + add(hostingController) + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + func setupToolbar() { + let detailsAction = UIAction(image: UIImage(systemName: "info.circle")) { [weak self] _ in + guard let self else { return } + let detailsStore = Store( + initialState: ArchiveDetailsFeature.State.init(archive: store.$archive, cached: store.cached) + ) { + ArchiveDetailsFeature() + } + + navigationController?.pushViewController( + UIHostingController(rootView: ArchiveDetailsV2(store: detailsStore, onDelete: { + if let viewControllers = self.navigationController?.viewControllers, viewControllers.count > 2 { + let destination = viewControllers[viewControllers.count - 3] + self.navigationController?.popToViewController(destination, animated: true) + } + }, onTagNavigation: { store in + self.navigationController?.pushViewController(UISearchViewController(store: store), animated: true) + })), + animated: true + ) + } + let detailsButton = UIBarButtonItem(primaryAction: detailsAction) + + navigationItem.rightBarButtonItem = detailsButton + } + + private func setupObserve() { + store.publisher.controlUiHidden + .sink { [weak self] hidden in + if hidden { + self?.navigationController?.setNavigationBarHidden(true, animated: false) + } else { + self?.navigationController?.setNavigationBarHidden(false, animated: false) + } + } + .store(in: &cancellables) + } + + override func viewDidLoad() { + super.viewDidLoad() + setupLayout() + setupToolbar() + setupObserve() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(store.controlUiHidden, animated: animated) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + navigationController?.setNavigationBarHidden(false, animated: animated) + } +} diff --git a/LANreader/Page/UIPageCell.swift b/LANreader/Page/UIPageCell.swift new file mode 100644 index 0000000..ea6441c --- /dev/null +++ b/LANreader/Page/UIPageCell.swift @@ -0,0 +1,185 @@ +import Combine +import ComposableArchitecture +import SwiftUI +import UIKit + +class UIPageCell: UICollectionViewCell { + static let reuseIdentifier = "UIPageCell" + + var store: StoreOf? + private var cellSize: CGSize? + private var cancellables: Set = [] + + private let scrollView: UIScrollView = { + let view = UIScrollView() + view.minimumZoomScale = 1.0 + view.maximumZoomScale = 3.0 + view.showsHorizontalScrollIndicator = false + view.showsVerticalScrollIndicator = false + return view + }() + + private let imageView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFit + view.clipsToBounds = true + return view + }() + + private let progressView: UIProgressView = { + let view = UIProgressView(progressViewStyle: .default) + view.progressTintColor = .label + return view + }() + + private let progressViewLabel: UILabel = { + let label = UILabel() + label.textAlignment = .natural + label.textColor = .label + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupImageView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupImageView() { + contentView.addSubview(scrollView) + scrollView.addSubview(imageView) + contentView.addSubview(progressView) + contentView.addSubview(progressViewLabel) + + scrollView.delegate = self + + scrollView.translatesAutoresizingMaskIntoConstraints = false + imageView.translatesAutoresizingMaskIntoConstraints = false + progressView.translatesAutoresizingMaskIntoConstraints = false + progressViewLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: contentView.topAnchor), + scrollView.leadingAnchor.constraint( + equalTo: contentView.leadingAnchor), + scrollView.trailingAnchor.constraint( + equalTo: contentView.trailingAnchor), + scrollView.bottomAnchor.constraint( + equalTo: contentView.bottomAnchor), + + imageView.topAnchor.constraint(equalTo: scrollView.topAnchor), + imageView.leadingAnchor.constraint( + equalTo: scrollView.leadingAnchor), + imageView.trailingAnchor.constraint( + equalTo: scrollView.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + imageView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + imageView.heightAnchor.constraint(equalTo: scrollView.heightAnchor), + + progressView.centerXAnchor.constraint( + equalTo: contentView.centerXAnchor), + progressView.centerYAnchor.constraint( + equalTo: contentView.centerYAnchor), + progressView.widthAnchor.constraint( + equalTo: contentView.widthAnchor, multiplier: 0.9), + + progressViewLabel.leadingAnchor.constraint( + equalTo: progressView.leadingAnchor), + progressViewLabel.topAnchor.constraint( + equalTo: progressView.bottomAnchor, constant: 8) + ]) + } + + func configure(with store: StoreOf, size: CGSize) { + self.store = store + self.cellSize = size + imageView.image = nil + progressView.progress = 0 + progressView.isHidden = true + progressViewLabel.isHidden = true + scrollView.zoomScale = 1.0 + setupObserve(store: store) + } + + func setupObserve(store: StoreOf) { + observe { [weak self] in + guard let self else { return } + + if store.pageMode == .loading { + imageView.isHidden = true + progressView.isHidden = false + progressViewLabel.isHidden = false + progressView.progress = Float(store.progress) + progressViewLabel.text = String( + format: "%.2f%%", store.progress * 100) + } else { + imageView.isHidden = false + progressView.isHidden = true + progressViewLabel.isHidden = true + let contentPath = { + switch store.pageMode { + case .left: + return store.pathLeft + case .right: + return store.pathRight + default: + return store.path + } + }() + + if let uiImage = UIImage( + contentsOfFile: contentPath?.path(percentEncoded: false) + ?? "") { + imageView.image = uiImage + } else { + imageView.image = UIImage(systemName: "rectangle.slash") + } + } + } + } + + func load(callback: () -> Void) async { + if store?.pageMode == .loading { + await store?.send(.load(false)).finish() + callback() + } + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.image = nil + progressView.progress = 0 + progressView.isHidden = true + scrollView.zoomScale = 1.0 + } + + override func preferredLayoutAttributesFitting( + _ layoutAttributes: UICollectionViewLayoutAttributes + ) -> UICollectionViewLayoutAttributes { + let attributes = super.preferredLayoutAttributesFitting( + layoutAttributes) + + if store?.pageMode == .loading || cellSize == nil + || imageView.image == nil + || store?.readDirection != ReadDirection.upDown.rawValue { + attributes.size = cellSize ?? .zero + return attributes + } else { + let height = + cellSize!.width * imageView.image!.size.height + / imageView.image!.size.width + attributes.size = CGSize(width: cellSize!.width, height: height) + } + + return attributes + } +} + +extension UIPageCell: UIScrollViewDelegate { + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return imageView + } +} diff --git a/LANreader/Page/UIPageCollection.swift b/LANreader/Page/UIPageCollection.swift new file mode 100644 index 0000000..a04374d --- /dev/null +++ b/LANreader/Page/UIPageCollection.swift @@ -0,0 +1,280 @@ +import Combine +import ComposableArchitecture +import SwiftUI +import UIKit + +public struct UIPageCollection: UIViewControllerRepresentable { + let store: StoreOf + let size: CGSize + + public init(store: StoreOf, size: CGSize) { + self.store = store + self.size = size + } + + public func makeUIViewController(context: Context) -> UIViewController { + UIPageCollectionController(store: store, size: size) + } + + public func updateUIViewController( + _ uiViewController: UIViewController, + context: Context + ) { + // Nothing to do + } +} + +class UIPageCollectionController: UICollectionViewController { + let store: StoreOf + let size: CGSize + + var dataSource: + UICollectionViewDiffableDataSource>! + var didInitialJump = false + + private var cancellables: Set = [] + + init(store: StoreOf, size: CGSize) { + self.store = store + self.size = size + + let heightDimension = + store.readDirection == ReadDirection.upDown.rawValue + ? NSCollectionLayoutDimension.estimated(size.height) + : NSCollectionLayoutDimension.fractionalHeight(1) + let itemSize = NSCollectionLayoutSize( + widthDimension: NSCollectionLayoutDimension.fractionalWidth(1), + heightDimension: heightDimension + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: itemSize, repeatingSubitem: item, count: 1) + + let section = NSCollectionLayoutSection(group: group) + if store.readDirection != ReadDirection.upDown.rawValue { + section.orthogonalScrollingBehavior = .groupPaging + } + + let layout = UICollectionViewCompositionalLayout(section: section) + + super.init(collectionViewLayout: layout) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupCollectionView() { + collectionView.bounces = false + collectionView.backgroundColor = .systemBackground + collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + collectionView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: self.view.topAnchor), + collectionView.bottomAnchor.constraint( + equalTo: self.view.bottomAnchor), + collectionView.leadingAnchor.constraint( + equalTo: self.view.leadingAnchor), + collectionView.trailingAnchor.constraint( + equalTo: self.view.trailingAnchor) + + ]) + } + + private func setupCell() { + collectionView.register( + UIPageCell.self, forCellWithReuseIdentifier: "Page") + let cellRegistration = UICollectionView.CellRegistration< + UIPageCell, StoreOf + > { [weak self] cell, _, pageStore in + guard self != nil else { return } + cell.configure(with: pageStore, size: self?.size ?? .zero) + } + + dataSource = UICollectionViewDiffableDataSource< + Section, StoreOf + >(collectionView: collectionView) { collectionView, indexPath, pageStore in + collectionView.dequeueConfiguredReusableCell( + using: cellRegistration, + for: indexPath, + item: pageStore + ) + } + } + + private func setupObserve() { + observe { [weak self] in + guard let self else { return } + guard !store.pages.isEmpty else { return } + var snapshot = NSDiffableDataSourceSnapshot< + Section, StoreOf + >() + snapshot.appendSections([.main]) + snapshot.appendItems( + Array(store.scope(state: \.pages, action: \.page))) + dataSource.apply(snapshot, animatingDifferences: false) + if !didInitialJump { + let indexPath = IndexPath(row: store.jumpIndex, section: 0) + collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false) + didInitialJump = true + } + } + + store.publisher.jumpIndex + .dropFirst() + .sink { [weak self] _ in + guard let self else { return } + guard collectionView.numberOfSections > 0 else { return } + let numberOfItems = collectionView.numberOfItems(inSection: 0) + guard store.jumpIndex < numberOfItems else { return } + let indexPath = IndexPath(row: store.jumpIndex, section: 0) + collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false) + } + .store(in: &cancellables) + } + + private func setupGesture() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) + collectionView.addGestureRecognizer(tapGesture) + tapGesture.delegate = self + } + + override func viewDidLoad() { + super.viewDidLoad() + setupCollectionView() + setupCell() + setupObserve() + setupGesture() + + collectionView.delegate = self + collectionView.prefetchDataSource = self + } + + enum Section { + case main + } +} + +extension UIPageCollectionController: UIGestureRecognizerDelegate { + enum TapRegion { + case left + case middle + case right + } + + @objc private func handleTap(_ gesture: UITapGestureRecognizer) { + let location = gesture.location(in: collectionView) + let width = collectionView.bounds.width + + // Determine which region was tapped + let region: TapRegion + switch location.x { + case ..<(width / 3): + region = .left + case (width / 3)..<(2 * width / 3): + region = .middle + default: + region = .right + } + + // Handle the tap based on region + handleTapInRegion(region, atLocation: location) + } + + private func handleTapInRegion(_ region: TapRegion, atLocation location: CGPoint) { + // Handle tap in region but not on any cell + if store.readDirection != ReadDirection.upDown.rawValue { + switch region { + case .left: + handleTapAction(action: store.tapLeft) + case .middle: + handleTapAction(action: store.tapMiddle) + case .right: + handleTapAction(action: store.tapRight) + } + } else { + store.send(.toggleControlUi(nil)) + } + } + + private func handleTapAction(action: String) { + switch action { + case PageControl.next.rawValue: + let row = store.sliderIndex.int + 1 + guard row < store.pages.count else { break } + let indexPath = IndexPath(row: store.sliderIndex.int + 1, section: 0) + collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) + case PageControl.previous.rawValue: + let row = store.sliderIndex.int - 1 + guard row >= 0 else { break } + let indexPath = IndexPath(row: store.sliderIndex.int - 1, section: 0) + collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) + case PageControl.navigation.rawValue: + store.send(.toggleControlUi(nil)) + default: + // This should not happen + break + } + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + return true + } +} + +extension UIPageCollectionController: UICollectionViewDataSourcePrefetching, + UICollectionViewDelegateFlowLayout { + + override func collectionView( + _ collectionView: UICollectionView, + willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath + ) { + store.send(.setSliderIndex(Double(indexPath.row))) + if let pageCell = cell as? UIPageCell { + Task { + await pageCell.load { + if self.store.readDirection == ReadDirection.upDown.rawValue { + collectionView.collectionViewLayout.invalidateLayout() + } + } + } + store.send(.updateProgress(pageCell.store?.pageNumber ?? 1)) + } + } + + override func collectionView( + _ collectionView: UICollectionView, + didEndDisplaying cell: UICollectionViewCell, + forItemAt indexPath: IndexPath + ) { } + + func collectionView( + _ collectionView: UICollectionView, + prefetchItemsAt indexPaths: [IndexPath] + ) { + indexPaths.forEach { path in + if let pageStore = dataSource.itemIdentifier(for: path) { + if pageStore.pageMode == .loading { + Task { + await pageStore.send(.load(false)).finish() + if self.store.readDirection + == ReadDirection.upDown.rawValue { + collectionView.collectionViewLayout + .invalidateLayout() + } + } + } + } + } + } + + func collectionView( + _ collectionView: UICollectionView, + cancelPrefetchingForItemsAt indexPaths: [IndexPath] + ) { + } +} diff --git a/LANreader/ReadSettings.swift b/LANreader/ReadSettings.swift index 1c7a6a4..e342ee7 100644 --- a/LANreader/ReadSettings.swift +++ b/LANreader/ReadSettings.swift @@ -2,9 +2,9 @@ import ComposableArchitecture import SwiftUI -@Reducer struct ReadSettingsFeature { +@Reducer public struct ReadSettingsFeature { @ObservableState - struct State: Equatable { + public struct State: Equatable { @Shared(.appStorage(SettingsKey.tapLeftKey)) var tapLeft = PageControl.next.rawValue @Shared(.appStorage(SettingsKey.tapMiddleKey)) var tapMiddle = PageControl.navigation.rawValue @Shared(.appStorage(SettingsKey.tapRightKey)) var tapRight = PageControl.previous.rawValue @@ -15,11 +15,11 @@ import SwiftUI @Shared(.appStorage(SettingsKey.splitPiorityLeft)) var splitPiorityLeft = false @Shared(.appStorage(SettingsKey.doublePageLayout)) var doublePageLayout = false } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) } - var body: some ReducerOf { + public var body: some ReducerOf { BindingReducer() Reduce { _, action in diff --git a/LANreader/Search/SearchViewV2.swift b/LANreader/Search/SearchViewV2.swift index 0eff3a5..327ea1e 100644 --- a/LANreader/Search/SearchViewV2.swift +++ b/LANreader/Search/SearchViewV2.swift @@ -3,11 +3,11 @@ import SwiftUI import NotificationBannerSwift import Logging -@Reducer struct SearchFeature { +@Reducer public struct SearchFeature { private let logger = Logger(label: "SearchFeature") @ObservableState - struct State: Equatable { + public struct State: Equatable { var keyword = "" var suggestedTag = [String]() var archiveList = ArchiveListFeature.State( @@ -17,9 +17,9 @@ import Logging ) } - enum Action: Equatable, BindableAction { + public enum Action: Equatable, BindableAction { case binding(BindingAction) - case generateSuggestion + case generateSuggestion(String) case suggestionTapped(String) case searchSubmit(String) case archiveList(ArchiveListFeature.Action) @@ -28,9 +28,9 @@ import Logging @Dependency(\.lanraragiService) var service @Dependency(\.appDatabase) var database - enum CancelId { case search } + public enum CancelId { case search } - var body: some ReducerOf { + public var body: some ReducerOf { Scope(state: \.archiveList, action: \.archiveList) { ArchiveListFeature() @@ -39,8 +39,8 @@ import Logging BindingReducer() Reduce { state, action in switch action { - case .generateSuggestion: - let lastToken = state.keyword.split( + case let .generateSuggestion(searchText): + let lastToken = searchText.split( separator: " ", omittingEmptySubsequences: false ).last.map(String.init) ?? "" @@ -62,10 +62,10 @@ import Logging state.keyword = "\(validKeyword) \(tag)$," return .none case let .searchSubmit(keyword): - guard !state.keyword.isEmpty else { + guard !keyword.isEmpty else { return .none } - state.suggestedTag = [] +// state.suggestedTag = [] state.archiveList.filter = SearchFilter(category: nil, filter: keyword) return .none case .binding: @@ -104,7 +104,7 @@ struct SearchViewV2: View { .navigationTitle("search") .navigationBarTitleDisplayMode(.inline) .onChange(of: store.keyword) { - store.send(.generateSuggestion) + store.send(.generateSuggestion("")) } } } diff --git a/LANreader/Search/UISearchView.swift b/LANreader/Search/UISearchView.swift new file mode 100644 index 0000000..f414798 --- /dev/null +++ b/LANreader/Search/UISearchView.swift @@ -0,0 +1,179 @@ +import ComposableArchitecture +import SwiftUI +import UIKit + +public struct UISearchView: UIViewControllerRepresentable { + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public func makeUIViewController(context: Context) -> UIViewController { + UINavigationController(rootViewController: UISearchViewController(store: store)) + } + + public func updateUIViewController( + _ uiViewController: UIViewController, + context: Context + ) { + // Nothing to do + } +} + +class UISearchViewController: UIViewController { + private let store: StoreOf + + // Constraint for dynamic height + private var suggestionsHeightConstraint: NSLayoutConstraint? + + // Constants + private let maxSuggestionsHeight: CGFloat = 300 + private let suggestionsRowHeight: CGFloat = 44 + + init(store: StoreOf) { + self.store = store + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private let searchBar: UISearchBar = { + let searchBar = UISearchBar() + searchBar.placeholder = "Search..." + searchBar.translatesAutoresizingMaskIntoConstraints = false + searchBar.searchBarStyle = .minimal + return searchBar + }() + + private let suggestionsTableView: UITableView = { + let tableView = UITableView() + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.isHidden = true + tableView.layer.cornerRadius = 8 + tableView.layer.borderWidth = 1 + return tableView + }() + + private func setupLayout() { + searchBar.text = store.keyword + view.addSubview(searchBar) + + let archiveListView = UIArchiveListViewController( + store: store.scope(state: \.archiveList, action: \.archiveList) + ) + add(archiveListView) + + view.addSubview(suggestionsTableView) + + suggestionsHeightConstraint = suggestionsTableView.heightAnchor.constraint(equalToConstant: 0) + suggestionsHeightConstraint?.isActive = true + + NSLayoutConstraint.activate([ + // Search bar constraints + searchBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8), + searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8), + + suggestionsTableView.topAnchor.constraint(equalTo: searchBar.bottomAnchor, constant: 8), + suggestionsTableView.leadingAnchor.constraint(equalTo: searchBar.leadingAnchor), + suggestionsTableView.trailingAnchor.constraint(equalTo: searchBar.trailingAnchor), + + archiveListView.view.topAnchor.constraint(equalTo: searchBar.bottomAnchor, constant: 8), + archiveListView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + archiveListView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + archiveListView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + private func setupDelegates() { + searchBar.delegate = self + suggestionsTableView.delegate = self + suggestionsTableView.dataSource = self + + suggestionsTableView.register(UITableViewCell.self, forCellReuseIdentifier: "SuggestionCell") + } + + private func setupObserve() { + observe { [weak self] in + guard let self else { return } + if store.archiveList.filter.filter?.isEmpty == false { + self.view.layoutIfNeeded() + } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupLayout() + setupDelegates() +// setupObserve() + } + + private func updateSuggestionsVisibility(for searchText: String) { + if !searchText.isEmpty && !store.suggestedTag.isEmpty { + // Calculate height based on number of suggestions + let contentHeight = CGFloat(store.suggestedTag.count) * suggestionsRowHeight + let newHeight = min(contentHeight, maxSuggestionsHeight) + + // Update height constraint with animation + UIView.animate(withDuration: 0.3) { + self.suggestionsHeightConstraint?.constant = newHeight + self.suggestionsTableView.isHidden = false + self.view.layoutIfNeeded() + } + } else { + UIView.animate(withDuration: 0.3) { + self.suggestionsHeightConstraint?.constant = 0 + self.suggestionsTableView.isHidden = true + self.view.layoutIfNeeded() + } + } + } +} + +extension UISearchViewController: UISearchBarDelegate { + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + Task { + await store.send(.generateSuggestion(searchText)).finish() + suggestionsTableView.isHidden = store.suggestedTag.isEmpty + suggestionsTableView.reloadData() + updateSuggestionsVisibility(for: searchText) + } + } + + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + guard let searchText = searchBar.text, !searchText.isEmpty else { return } + store.send(.searchSubmit(searchText)) + suggestionsTableView.isHidden = true + searchBar.resignFirstResponder() + } +} + +extension UISearchViewController: UITableViewDelegate, UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return store.suggestedTag.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "SuggestionCell", for: indexPath) + cell.textLabel?.text = store.suggestedTag[indexPath.row] + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let suggestion = store.suggestedTag[indexPath.row] + let validKeyword = searchBar.text?.split(separator: " ").dropLast(1).joined(separator: " ") ?? "" + searchBar.text = "\(validKeyword) \(suggestion)$," + UIView.animate(withDuration: 0.3) { + self.suggestionsHeightConstraint?.constant = 0 + tableView.isHidden = true + self.view.layoutIfNeeded() + } + tableView.deselectRow(at: indexPath, animated: true) + } + +} diff --git a/LANreader/Service/ImageService.swift b/LANreader/Service/ImageService.swift index 8b6d8c4..d2d76a2 100644 --- a/LANreader/Service/ImageService.swift +++ b/LANreader/Service/ImageService.swift @@ -1,6 +1,5 @@ import UIKit import Dependencies -import func AVFoundation.AVMakeRect class ImageService { private static var _shared: ImageService? @@ -10,55 +9,22 @@ class ImageService { try? image.heicData()?.write(to: destinationUrl) } - func resizeImage(imageUrl: URL, destinationUrl: URL, pageNumber: String, split: Bool, skip: Bool) -> Bool { + func resizeImage(imageUrl: URL, destinationUrl: URL, pageNumber: String, split: Bool) -> Bool { try? FileManager.default.createDirectory(at: destinationUrl, withIntermediateDirectories: true) let mainPath = destinationUrl.appendingPathComponent("\(pageNumber).heic", conformingTo: .heic) guard let image = UIImage(contentsOfFile: imageUrl.path(percentEncoded: false)) else { return false } var splitted = false - let screenRect = UIScreen.main.bounds - let rect = AVMakeRect(aspectRatio: image.size, insideRect: screenRect) - let normalizedRect = CGRect(origin: .zero, size: rect.size) - let renderer = UIGraphicsImageRenderer(size: normalizedRect.size) - - if skip { - try? image.heicData()?.write(to: mainPath) - } else { - try? renderer.image { _ in - image.draw(in: normalizedRect) - }.heicData()?.write(to: mainPath) - } + try? image.heicData()?.write(to: mainPath) if split && (image.size.width / image.size.height > 1.2) { let leftPath = destinationUrl.appendingPathComponent("\(pageNumber)-left.heic", conformingTo: .heic) let rightPath = destinationUrl.appendingPathComponent("\(pageNumber)-right.heic", conformingTo: .heic) - if skip { - try? image.leftHalf?.heicData()?.write(to: leftPath) - try? image.rightHalf?.heicData()?.write(to: rightPath) - } else { - let leftImage = image.leftHalf - let leftRect = AVMakeRect( - aspectRatio: leftImage?.size ?? .zero, - insideRect: screenRect - ) - let normalizedLeftRect = CGRect(origin: .zero, size: leftRect.size) - let leftRenderer = UIGraphicsImageRenderer(size: normalizedLeftRect.size) - try? leftRenderer.image { _ in - leftImage?.draw(in: normalizedLeftRect) - }.heicData()?.write(to: leftPath) - let rightImage = image.rightHalf - let rightRect = AVMakeRect( - aspectRatio: rightImage?.size ?? .zero, - insideRect: screenRect - ) - let normalizedRightRect = CGRect(origin: .zero, size: rightRect.size) - let rightRenderer = UIGraphicsImageRenderer(size: normalizedRightRect.size) - try? rightRenderer.image { _ in - rightImage?.draw(in: normalizedRightRect) - }.heicData()?.write(to: rightPath) - } + try? image.leftHalf?.heicData()?.write(to: leftPath) + try? image.rightHalf?.heicData()?.write(to: rightPath) + splitted = true } return splitted diff --git a/LANreader/Service/LANraragiService.swift b/LANreader/Service/LANraragiService.swift index e159c30..296497e 100644 --- a/LANreader/Service/LANraragiService.swift +++ b/LANreader/Service/LANraragiService.swift @@ -37,9 +37,8 @@ class LANraragiService: NSObject { private static var _shared: LANraragiService? private var url = UserDefaults.standard.string(forKey: SettingsKey.lanraragiUrl) ?? "" - private let authInterceptor = AuthInterceptor() + private var authInterceptor = AuthInterceptor(apiKey: nil) private var session: Session - private var prefetchSession: Session private let snakeCaseEncoder: JSONDecoder private let imageService = ImageService.shared @@ -52,14 +51,15 @@ class LANraragiService: NSObject { private override init() { self.session = Session(interceptor: authInterceptor) - self.prefetchSession = Session(interceptor: authInterceptor) self.snakeCaseEncoder = JSONDecoder() self.snakeCaseEncoder.keyDecodingStrategy = .convertFromSnakeCase } func verifyClient(url: String, apiKey: String) async -> DataTask { self.url = url - self.authInterceptor.updateApiKey(apiKey) + let interceptor = AuthInterceptor(apiKey: apiKey) + self.authInterceptor = interceptor + self.session = Session(interceptor: interceptor) let cacher = ResponseCacher(behavior: .doNotCache) return session.request("\(self.url)/api/info") .cacheResponse(using: cacher) @@ -279,12 +279,12 @@ class LANraragiService: NSObject { } } -class AuthInterceptor: RequestInterceptor { +final class AuthInterceptor: RequestInterceptor { - private var apiKey = UserDefaults.standard.string(forKey: SettingsKey.lanraragiApiKey) ?? "" + private let apiKey: String - func updateApiKey(_ apiKey: String) { - self.apiKey = apiKey + init(apiKey: String?) { + self.apiKey = apiKey ?? UserDefaults.standard.string(forKey: SettingsKey.lanraragiApiKey) ?? "" } func adapt(_ urlRequest: URLRequest, @@ -315,8 +315,6 @@ extension DependencyValues { extension LANraragiService: URLSessionDelegate, URLSessionDownloadDelegate { func urlSession(_: URLSession, downloadTask task: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { let splitImage = UserDefaults.standard.bool(forKey: SettingsKey.splitWideImage) - let fallback = UserDefaults.standard.bool(forKey: SettingsKey.fallbackReader) - let showOriginal = UserDefaults.standard.bool(forKey: SettingsKey.showOriginal) if let archiveId = task.originalRequest?.value(forHTTPHeaderField: "X-Archive-Id"), let pageNumber = task.originalRequest?.value(forHTTPHeaderField: "X-Page-Number"), @@ -326,8 +324,7 @@ extension LANraragiService: URLSessionDelegate, URLSessionDownloadDelegate { imageUrl: location, destinationUrl: folder, pageNumber: pageNumber, - split: splitImage && !fallback, - skip: showOriginal + split: splitImage ) } } diff --git a/LANreader/Setting/DatabaseSettings.swift b/LANreader/Setting/DatabaseSettings.swift index 07a8109..cefdb73 100644 --- a/LANreader/Setting/DatabaseSettings.swift +++ b/LANreader/Setting/DatabaseSettings.swift @@ -3,22 +3,22 @@ import ComposableArchitecture import SwiftUI import Logging -@Reducer struct DatabaseSettingsFeature { +@Reducer public struct DatabaseSettingsFeature { private let logger = Logger(label: "DatabaseSettingsFeature") @ObservableState - struct State: Equatable { + public struct State: Equatable { var size = "" } - enum Action: Equatable { + public enum Action: Equatable { case setDatabaseSize case clearDatabase } @Dependency(\.appDatabase) var database - func reduce(into state: inout State, action: Action) -> Effect { + public func reduce(into state: inout State, action: Action) -> Effect { switch action { case .setDatabaseSize: do { diff --git a/LANreader/Setting/LogView.swift b/LANreader/Setting/LogView.swift index 02107a1..99647f2 100644 --- a/LANreader/Setting/LogView.swift +++ b/LANreader/Setting/LogView.swift @@ -4,17 +4,17 @@ import ComposableArchitecture import SwiftUI -@Reducer struct LogFeature { +@Reducer public struct LogFeature { @ObservableState - struct State: Equatable { + public struct State: Equatable { var log = "" } - enum Action: Equatable { + public enum Action: Equatable { case setLog(String) } - var body: some Reducer { + public var body: some Reducer { Reduce { state, action in switch action { case let .setLog(log): diff --git a/LANreader/Setting/UploadView.swift b/LANreader/Setting/UploadView.swift index 6407cdb..2170617 100644 --- a/LANreader/Setting/UploadView.swift +++ b/LANreader/Setting/UploadView.swift @@ -2,16 +2,16 @@ import ComposableArchitecture import SwiftUI import Logging -@Reducer struct UploadFeature { +@Reducer public struct UploadFeature { private let logger = Logger(label: "UploadFeature") @ObservableState - struct State: Equatable { + public struct State: Equatable { var urls = "" var jobDetails: [Int: DownloadJob] = .init() } - enum Action: Equatable, BindableAction { + public enum Action: Equatable, BindableAction { case binding(BindingAction) case queueDownload(String) case addJobDetails([DownloadJob]) @@ -22,7 +22,7 @@ import Logging @Dependency(\.appDatabase) var database @Dependency(\.continuousClock) var clock - var body: some Reducer { + public var body: some Reducer { BindingReducer() Reduce { state, action in switch action { diff --git a/LANreader/SettingsView.swift b/LANreader/SettingsView.swift index e916fe4..dcdb9b0 100644 --- a/LANreader/SettingsView.swift +++ b/LANreader/SettingsView.swift @@ -2,16 +2,16 @@ import ComposableArchitecture import SwiftUI -@Reducer struct SettingsFeature { +@Reducer public struct SettingsFeature { @ObservableState - struct State: Equatable { + public struct State: Equatable { var path = StackState() var read = ReadSettingsFeature.State() var view = ViewSettingsFeature.State() var database = DatabaseSettingsFeature.State() } - enum Action { + public enum Action { case path(StackAction) case read(ReadSettingsFeature.Action) @@ -19,7 +19,7 @@ import SwiftUI case database(DatabaseSettingsFeature.Action) } - var body: some ReducerOf { + public var body: some ReducerOf { Scope(state: \.read, action: \.read) { ReadSettingsFeature() } @@ -42,7 +42,7 @@ import SwiftUI } @Reducer(state: .equatable) - enum Path { + public enum Path { case lanraragiSettings(LANraragiConfigFeature) case upload(UploadFeature) case log(LogFeature) diff --git a/LANreader/ViewSettings.swift b/LANreader/ViewSettings.swift index 12b945e..1bebaa0 100644 --- a/LANreader/ViewSettings.swift +++ b/LANreader/ViewSettings.swift @@ -2,9 +2,9 @@ import ComposableArchitecture import SwiftUI -@Reducer struct ViewSettingsFeature { +@Reducer public struct ViewSettingsFeature { @ObservableState - struct State: Equatable { + public struct State: Equatable { @Presents var destination: Destination.State? @Shared(.appStorage(SettingsKey.searchSortCustom)) var searchSortCustom = "" @@ -13,7 +13,7 @@ import SwiftUI @Shared(.appStorage(SettingsKey.passcode)) var storedPasscode = "" } - enum Action: BindableAction { + public enum Action: BindableAction { case binding(BindingAction) case destination(PresentationAction) @@ -22,7 +22,7 @@ import SwiftUI case showLockScreen(Bool) } - var body: some Reducer { + public var body: some Reducer { BindingReducer() Reduce { state, action in @@ -43,7 +43,7 @@ import SwiftUI } @Reducer(state: .equatable) - enum Destination { + public enum Destination { case lockScreen(LockScreenFeature) } } diff --git a/LANreaderTests/Resources/ServerInfoResponse.json b/LANreaderTests/Resources/ServerInfoResponse.json index 7fa094a..becd69f 100644 --- a/LANreaderTests/Resources/ServerInfoResponse.json +++ b/LANreaderTests/Resources/ServerInfoResponse.json @@ -1,12 +1,16 @@ { - "archives_per_page": "100", - "debug_mode": "0", - "has_password": "1", - "motd": "Welcome to this Library running LANraragi!", - "name": "LANraragi", - "nofun_mode": "1", - "server_resizes_images": "0", - "version": "0.7.2", - "version_name": "Neighborhood Threat", - "serverTracksProgress": "1" + "archives_per_page": 100, + "cache_last_cleared": 1730409286, + "debug_mode": false, + "has_password": true, + "motd": "Welcome to this Library running LANraragi!", + "name": "LANraragi", + "nofun_mode": true, + "server_resizes_images": false, + "server_tracks_progress": true, + "total_archives": 10559, + "total_pages_read": 65589, + "version": "0.9.30", + "version_desc": "I'm under Japanese influence and my honor's at stake!", + "version_name": "Law (Earthlings On Fire)" } diff --git a/LANreaderTests/Service/LANraragiServiceTest.swift b/LANreaderTests/Service/LANraragiServiceTest.swift index 981c95a..62e1111 100644 --- a/LANreaderTests/Service/LANraragiServiceTest.swift +++ b/LANreaderTests/Service/LANraragiServiceTest.swift @@ -38,9 +38,9 @@ class LANraragiServiceTest: XCTestCase { } let actual = try await service.verifyClient(url: url, apiKey: apiKey).value - XCTAssertEqual(actual.archivesPerPage, "100") - XCTAssertEqual(actual.debugMode, "0") - XCTAssertEqual(actual.hasPassword, "1") + XCTAssertEqual(actual.archivesPerPage, 100) + XCTAssertEqual(actual.debugMode, false) + XCTAssertEqual(actual.hasPassword, true) } func testGetServerInfoUnauthorized() async throws {