diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml new file mode 100644 index 00000000..751ff046 --- /dev/null +++ b/.github/workflows/dependencies.yml @@ -0,0 +1,30 @@ +name: Xcode Dependencies +on: + schedule: + - cron: '0 0 * * 1' + push: + branches: + - main +permissions: + contents: write + pull-requests: write +jobs: + dependencies: + runs-on: macos-13 + if: ${{ contains(github.event.head_commit.message, '[update dependencies]') || github.event_name == 'schedule' }} + steps: + - uses: actions/checkout@v2 + - name: Resolve Dependencies + id: resolution + uses: GetSidetrack/action-xcodeproj-spm-update@main + with: + forceResolution: true + failWhenOutdated: false + - name: Create Pull Request + if: steps.resolution.outputs.dependenciesChanged == 'true' + uses: peter-evans/create-pull-request@v3 + with: + branch: 'update-dependencies' + delete-branch: true + commit-message: 'Update dependencies [skip test]' + title: 'Updated Dependencies' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1ad012e4..11ad7064 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,8 +5,8 @@ on: - main types: [closed] env: - DEVELOPER_DIR: /Applications/Xcode_14.1.app - APP_VERSION: '2.6.1' + DEVELOPER_DIR: /Applications/Xcode_14.3.app + APP_VERSION: '2.7.0' SCHEME_NAME: 'EhPanda' ALTSTORE_JSON_PATH: './AltStore.json' BUILDS_PATH: '/tmp/action-builds' @@ -19,7 +19,7 @@ env: jobs: Deploy: - runs-on: macos-12 + runs-on: macos-13 if: github.event.pull_request.merged == true && github.event.pull_request.user.login == 'chihchy' steps: - name: Checkout @@ -34,7 +34,7 @@ jobs: run: xcodebuild -version - name: Run tests run: xcodebuild clean test -scheme ${{ env.SCHEME_NAME }} -sdk iphonesimulator - -destination 'platform=iOS Simulator,name=iPhone 14' + -destination 'platform=iOS Simulator,name=iPhone 14 Pro' - name: Bump version id: bump-version uses: yanamura/ios-bump-version@v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 025be285..e83cb865 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,10 +2,11 @@ name: Test on: [push] env: SCHEME_NAME: 'EhPanda' - DEVELOPER_DIR: /Applications/Xcode_14.1.app + DEVELOPER_DIR: /Applications/Xcode_14.3.app jobs: Test: - runs-on: macos-12 + runs-on: macos-13 + if: ${{ !contains(github.event.head_commit.message, '[skip test]') }} steps: - name: Checkout uses: actions/checkout@v2 @@ -16,4 +17,4 @@ jobs: - name: Run tests run: xcodebuild clean test -scheme ${{ env.SCHEME_NAME }} -sdk iphonesimulator - -destination 'platform=iOS Simulator,name=iPhone 14' + -destination 'platform=iOS Simulator,name=iPhone 14 Pro' diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index 70f00ef3..e96869f1 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -7,18 +7,18 @@ objects = { /* Begin PBXBuildFile section */ - AB0929B6277F043D00F107CA /* AccountSettingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929B5277F043D00F107CA /* AccountSettingStore.swift */; }; - AB0929BE2780032400F107CA /* EhSettingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BD2780032400F107CA /* EhSettingStore.swift */; }; - AB0929C027805A8200F107CA /* LoginStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BF27805A8200F107CA /* LoginStore.swift */; }; + AB0929B6277F043D00F107CA /* AccountSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929B5277F043D00F107CA /* AccountSettingReducer.swift */; }; + AB0929BE2780032400F107CA /* EhSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BD2780032400F107CA /* EhSettingReducer.swift */; }; + AB0929C027805A8200F107CA /* LoginReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929BF27805A8200F107CA /* LoginReducer.swift */; }; AB0929C6278160AE00F107CA /* LibraryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929C5278160AE00F107CA /* LibraryClient.swift */; }; AB0929C82781938A00F107CA /* DFClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929C72781938A00F107CA /* DFClient.swift */; }; - AB0929CA278196ED00F107CA /* CookiesClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929C9278196ED00F107CA /* CookiesClient.swift */; }; - AB0929CC2781A0B000F107CA /* HapticClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929CB2781A0B000F107CA /* HapticClient.swift */; }; + AB0929CA278196ED00F107CA /* CookieClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929C9278196ED00F107CA /* CookieClient.swift */; }; + AB0929CC2781A0B000F107CA /* HapticsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929CB2781A0B000F107CA /* HapticsClient.swift */; }; AB0929CE2781AADA00F107CA /* DatabaseClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929CD2781AADA00F107CA /* DatabaseClient.swift */; }; AB0929D02781E1CC00F107CA /* UIApplicationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929CF2781E1CC00F107CA /* UIApplicationClient.swift */; }; AB0929D22781E7D500F107CA /* LoggerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929D12781E7D500F107CA /* LoggerClient.swift */; }; AB0929D42781EDDC00F107CA /* UserDefaultsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929D32781EDDC00F107CA /* UserDefaultsClient.swift */; }; - AB0929D62782A65F00F107CA /* GeneralSettingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929D52782A65F00F107CA /* GeneralSettingStore.swift */; }; + AB0929D62782A65F00F107CA /* GeneralSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929D52782A65F00F107CA /* GeneralSettingReducer.swift */; }; AB0929D82782A83A00F107CA /* AuthorizationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0929D72782A83A00F107CA /* AuthorizationClient.swift */; }; AB0ABCB526C5406400AD970F /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0ABCB426C5406400AD970F /* LoginView.swift */; }; AB0ABCB726C541A400AD970F /* WaveForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0ABCB626C541A400AD970F /* WaveForm.swift */; }; @@ -63,7 +63,6 @@ AB2EB99F280251D600011A8A /* TTProgressHUD in Frameworks */ = {isa = PBXBuildFile; productRef = AB2EB99E280251D600011A8A /* TTProgressHUD */; }; AB2EB9A2280251F600011A8A /* AlertKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB2EB9A1280251F600011A8A /* AlertKit */; }; AB2EB9A52802521700011A8A /* DeprecatedAPI in Frameworks */ = {isa = PBXBuildFile; productRef = AB2EB9A42802521700011A8A /* DeprecatedAPI */; }; - AB2EB9AB2802525700011A8A /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = AB2EB9AA2802525700011A8A /* SwiftyBeaver */; }; AB3072D2276D734800EFF242 /* SubSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3072D1276D734800EFF242 /* SubSection.swift */; }; AB3072D4276E19AA00EFF242 /* FrontpageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3072D3276E19AA00EFF242 /* FrontpageView.swift */; }; AB31CD3027B666E200F40E0A /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB31CD2F27B666E200F40E0A /* TestError.swift */; }; @@ -104,8 +103,8 @@ AB41DB5027B760D700DD3604 /* PopularCompactList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3B27B760D700DD3604 /* PopularCompactList.html */; }; AB41DB5127B760D700DD3604 /* PopularMinimalList.html in Resources */ = {isa = PBXBuildFile; fileRef = AB41DB3C27B760D700DD3604 /* PopularMinimalList.html */; }; AB4FD2C1268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4FD2C0268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift */; }; - AB58A5AC2776B2BC00C0D285 /* AppDelegateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB58A5AB2776B2BC00C0D285 /* AppDelegateStore.swift */; }; - AB58A5B22776B99000C0D285 /* AppStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB58A5B12776B99000C0D285 /* AppStore.swift */; }; + AB58A5AC2776B2BC00C0D285 /* AppDelegateReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB58A5AB2776B2BC00C0D285 /* AppDelegateReducer.swift */; }; + AB58A5B22776B99000C0D285 /* AppReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB58A5B12776B99000C0D285 /* AppReducer.swift */; }; AB5BE67926B95FDD007D4A55 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB5BE67826B95FDD007D4A55 /* ShareViewController.swift */; }; AB5BE68026B95FDD007D4A55 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; AB60D0E9274C7ECE00F899AB /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = AB60D0E8274C7ECE00F899AB /* WaterfallGrid */; }; @@ -114,28 +113,27 @@ AB6505A026B0027800F91E9D /* SwiftUIPager in Frameworks */ = {isa = PBXBuildFile; productRef = AB65059F26B0027800F91E9D /* SwiftUIPager */; }; AB69CB8026B3DABC00699359 /* AdvancedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB69CB7F26B3DABC00699359 /* AdvancedList.swift */; }; AB69CB8226B3DAF400699359 /* ControlPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB69CB8126B3DAF400699359 /* ControlPanel.swift */; }; - AB6D106A27EBF890003A2245 /* GeneralSettingStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6D106927EBF890003A2245 /* GeneralSettingStoreTests.swift */; }; AB6DE897268822390087C579 /* LogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6DE896268822390087C579 /* LogsView.swift */; }; - AB706F7927890A6C0025A48A /* AppRouteStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F7827890A6C0025A48A /* AppRouteStore.swift */; }; - AB706F7B278937500025A48A /* FrontpageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F7A278937500025A48A /* FrontpageStore.swift */; }; + AB706F7927890A6C0025A48A /* AppRouteReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F7827890A6C0025A48A /* AppRouteReducer.swift */; }; + AB706F7B278937500025A48A /* FrontpageReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F7A278937500025A48A /* FrontpageReducer.swift */; }; AB706F80278981370025A48A /* AlertKit_Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F7F278981370025A48A /* AlertKit_Extension.swift */; }; AB706F82278986120025A48A /* ToolbarItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F81278986120025A48A /* ToolbarItems.swift */; }; AB706F842789AD2D0025A48A /* ToplistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F832789AD2D0025A48A /* ToplistsView.swift */; }; - AB706F862789AD490025A48A /* ToplistsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F852789AD490025A48A /* ToplistsStore.swift */; }; + AB706F862789AD490025A48A /* ToplistsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F852789AD490025A48A /* ToplistsReducer.swift */; }; AB706F88278A4C8A0025A48A /* PopularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F87278A4C8A0025A48A /* PopularView.swift */; }; - AB706F8A278A4CC50025A48A /* PopularStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F89278A4CC50025A48A /* PopularStore.swift */; }; + AB706F8A278A4CC50025A48A /* PopularReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F89278A4CC50025A48A /* PopularReducer.swift */; }; AB706F8C278A4F6C0025A48A /* WatchedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F8B278A4F6C0025A48A /* WatchedView.swift */; }; AB706F8E278A5DCF0025A48A /* DeviceClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F8D278A5DCF0025A48A /* DeviceClient.swift */; }; AB706F90278A5F680025A48A /* AppDelegateClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F8F278A5F680025A48A /* AppDelegateClient.swift */; }; - AB706F92278A6E8C0025A48A /* WatchedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F91278A6E8C0025A48A /* WatchedStore.swift */; }; + AB706F92278A6E8C0025A48A /* WatchedReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F91278A6E8C0025A48A /* WatchedReducer.swift */; }; AB706F95278A75D30025A48A /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F94278A75D30025A48A /* HistoryView.swift */; }; - AB706F97278A77E20025A48A /* HistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F96278A77E20025A48A /* HistoryStore.swift */; }; - AB706F99278A820C0025A48A /* FiltersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F98278A820C0025A48A /* FiltersStore.swift */; }; + AB706F97278A77E20025A48A /* HistoryReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F96278A77E20025A48A /* HistoryReducer.swift */; }; + AB706F99278A820C0025A48A /* FiltersReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F98278A820C0025A48A /* FiltersReducer.swift */; }; AB706F9B278AC5A30025A48A /* SearchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F9A278AC5A30025A48A /* SearchRootView.swift */; }; - AB706F9D278ACCA20025A48A /* SearchRootStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F9C278ACCA20025A48A /* SearchRootStore.swift */; }; + AB706F9D278ACCA20025A48A /* SearchRootReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F9C278ACCA20025A48A /* SearchRootReducer.swift */; }; AB706F9F278AD4800025A48A /* GalleryHistoryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F9E278AD4800025A48A /* GalleryHistoryCell.swift */; }; AB706FA1278BCEC60025A48A /* DetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706FA0278BCEC60025A48A /* DetailView.swift */; }; - AB706FA3278BCF2F0025A48A /* DetailStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706FA2278BCF2F0025A48A /* DetailStore.swift */; }; + AB706FA3278BCF2F0025A48A /* DetailReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706FA2278BCF2F0025A48A /* DetailReducer.swift */; }; AB706FA5278C3DDE0025A48A /* PreviewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706FA4278C3DDE0025A48A /* PreviewsView.swift */; }; AB7B29F226AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7B29F126AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift */; }; AB7B29F626AC741600EE1F14 /* GenericList.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7B29F526AC741600EE1F14 /* GenericList.swift */; }; @@ -153,13 +151,13 @@ AB7BF2CC27A96A3C001865A3 /* GalleryTorrent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2CB27A96A3C001865A3 /* GalleryTorrent.swift */; }; AB7BF2CE27AA3E58001865A3 /* AppUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2CD27AA3E58001865A3 /* AppUtil.swift */; }; AB7BF2D027AA3E75001865A3 /* DeviceUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2CF27AA3E75001865A3 /* DeviceUtil.swift */; }; - AB7BF2D227AA3EDC001865A3 /* HapticUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D127AA3EDC001865A3 /* HapticUtil.swift */; }; - AB7BF2D427AA3F12001865A3 /* CookiesUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D327AA3F12001865A3 /* CookiesUtil.swift */; }; + AB7BF2D227AA3EDC001865A3 /* HapticsUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D127AA3EDC001865A3 /* HapticsUtil.swift */; }; + AB7BF2D427AA3F12001865A3 /* CookieUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D327AA3F12001865A3 /* CookieUtil.swift */; }; AB7BF2D627AA3F4C001865A3 /* FileUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D527AA3F4C001865A3 /* FileUtil.swift */; }; AB7BF2D827AA3F61001865A3 /* UserDefaultsUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D727AA3F61001865A3 /* UserDefaultsUtil.swift */; }; AB7BF2DA27AA78CF001865A3 /* Reducer_Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D927AA78CF001865A3 /* Reducer_Extension.swift */; }; AB7BF2FB27ABCA3A001865A3 /* MigrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2FA27ABCA3A001865A3 /* MigrationView.swift */; }; - AB7BF2FD27ABCAD4001865A3 /* MigrationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2FC27ABCAD4001865A3 /* MigrationStore.swift */; }; + AB7BF2FD27ABCAD4001865A3 /* MigrationReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2FC27ABCAD4001865A3 /* MigrationReducer.swift */; }; AB7BF30727ABDFF1001865A3 /* CoreDataMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2FE27ABDFF1001865A3 /* CoreDataMigrator.swift */; }; AB7BF30A27ABDFF1001865A3 /* CoreDataMigrationStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF30327ABDFF1001865A3 /* CoreDataMigrationStep.swift */; }; AB7BF30D27ABDFF1001865A3 /* CoreDataMigrationVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF30627ABDFF1001865A3 /* CoreDataMigrationVersion.swift */; }; @@ -168,45 +166,42 @@ AB7BF31D27ABE028001865A3 /* FileManager+ApplicationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF31627ABE028001865A3 /* FileManager+ApplicationSupport.swift */; }; AB7BF31E27ABE028001865A3 /* NSPersistentStoreCoordinator+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF31827ABE028001865A3 /* NSPersistentStoreCoordinator+SQLite.swift */; }; AB7E6B3025D24FE00035CC68 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AB7E6B3225D24FE00035CC68 /* InfoPlist.strings */; }; - AB86ABF52782DAB300E61E6A /* LogsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86ABF42782DAB300E61E6A /* LogsStore.swift */; }; + AB86ABF52782DAB300E61E6A /* LogsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86ABF42782DAB300E61E6A /* LogsReducer.swift */; }; AB86ABF72782DDE600E61E6A /* FileClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86ABF62782DDE600E61E6A /* FileClient.swift */; }; - AB86ABF92782EC0D00E61E6A /* EhPandaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86ABF82782EC0D00E61E6A /* EhPandaView.swift */; }; - AB86AC0A2782FAFA00E61E6A /* AppearanceSettingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86AC092782FAFA00E61E6A /* AppearanceSettingStore.swift */; }; + AB86ABF92782EC0D00E61E6A /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86ABF82782EC0D00E61E6A /* AboutView.swift */; }; + AB86AC0A2782FAFA00E61E6A /* AppearanceSettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86AC092782FAFA00E61E6A /* AppearanceSettingReducer.swift */; }; AB86AC1027831AD100E61E6A /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = AB86AC0F27831AD100E61E6A /* ComposableArchitecture */; }; - AB86AC1327856F2700E61E6A /* AppLockStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86AC1227856F2700E61E6A /* AppLockStore.swift */; }; - AB86AC1A2785C2B300E61E6A /* HomeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86AC192785C2B300E61E6A /* HomeStore.swift */; }; + AB86AC1327856F2700E61E6A /* AppLockReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86AC1227856F2700E61E6A /* AppLockReducer.swift */; }; + AB86AC1A2785C2B300E61E6A /* HomeReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB86AC192785C2B300E61E6A /* HomeReducer.swift */; }; AB8C821926BF801700E8C5E6 /* EhSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8C821826BF801700E8C5E6 /* EhSetting.swift */; }; AB90276B291F548700697256 /* AppIcon_NotMyPresident@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB902766291F548600697256 /* AppIcon_NotMyPresident@3x.png */; }; AB90276C291F548700697256 /* AppIcon_NotMyPresident_iPad@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB902767291F548600697256 /* AppIcon_NotMyPresident_iPad@2x.png */; }; AB90276D291F548700697256 /* AppIcon_NotMyPresident_iPad_Pro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB902768291F548700697256 /* AppIcon_NotMyPresident_iPad_Pro@2x.png */; }; AB90276E291F548700697256 /* AppIcon_NotMyPresident_iPad.png in Resources */ = {isa = PBXBuildFile; fileRef = AB902769291F548700697256 /* AppIcon_NotMyPresident_iPad.png */; }; AB90276F291F548700697256 /* AppIcon_NotMyPresident@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = AB90276A291F548700697256 /* AppIcon_NotMyPresident@2x.png */; }; - ABA12F3227D49CEB0021922D /* AccountSettingStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA12F3127D49CEB0021922D /* AccountSettingStoreTests.swift */; }; ABA732D925A8018A00B3D9AB /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA732D825A8018A00B3D9AB /* Extensions.swift */; }; ABA732DF25A852D800B3D9AB /* Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA732DE25A852D800B3D9AB /* Filter.swift */; }; ABA9A6BC28EC786100EE28DE /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = ABA9A6BB28EC786100EE28DE /* swiftgen.yml */; }; ABA9A6C228EC7BD000EE28DE /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA9A6C128EC7BD000EE28DE /* Strings.swift */; }; ABAB5B9527EF023300198597 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABAB5B9427EF023300198597 /* Extensions.swift */; }; - ABAB5B9727EF03F600198597 /* AppearanceSettingStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABAB5B9627EF03F600198597 /* AppearanceSettingStoreTests.swift */; }; ABAC82FE26BC4A96009F5026 /* OpenCC in Frameworks */ = {isa = PBXBuildFile; productRef = ABAC82FD26BC4A96009F5026 /* OpenCC */; }; ABBB2631278E6EF3007B6149 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2630278E6EF3007B6149 /* SearchView.swift */; }; - ABBB2633278E6F3B007B6149 /* SearchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2632278E6F3B007B6149 /* SearchStore.swift */; }; ABBB2636278FB888007B6149 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = ABBB2635278FB888007B6149 /* SwiftUINavigation */; }; ABBB2638278FBD2F007B6149 /* SwiftUINavigation_Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2637278FBD2F007B6149 /* SwiftUINavigation_Extension.swift */; }; ABBB263A2792588F007B6149 /* TTProgressHUD_Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB26392792588F007B6149 /* TTProgressHUD_Extension.swift */; }; - ABBB263E2793C648007B6149 /* PreviewsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB263D2793C648007B6149 /* PreviewsStore.swift */; }; - ABBB2640279417EC007B6149 /* CommentsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB263F279417EC007B6149 /* CommentsStore.swift */; }; + ABBB263E2793C648007B6149 /* PreviewsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB263D2793C648007B6149 /* PreviewsReducer.swift */; }; + ABBB2640279417EC007B6149 /* CommentsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB263F279417EC007B6149 /* CommentsReducer.swift */; }; ABBB264227942B74007B6149 /* URLClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB264127942B74007B6149 /* URLClient.swift */; }; - ABBB266627977C2A007B6149 /* ArchivesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB266527977C2A007B6149 /* ArchivesStore.swift */; }; + ABBB266627977C2A007B6149 /* ArchivesReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB266527977C2A007B6149 /* ArchivesReducer.swift */; }; ABBB26682797BFAA007B6149 /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB26672797BFAA007B6149 /* ActivityView.swift */; }; - ABBB266A2797C61F007B6149 /* TorrentsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB26692797C61F007B6149 /* TorrentsStore.swift */; }; + ABBB266A2797C61F007B6149 /* TorrentsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB26692797C61F007B6149 /* TorrentsReducer.swift */; }; ABBB266C2797E882007B6149 /* ClipboardClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB266B2797E882007B6149 /* ClipboardClient.swift */; }; - ABBB266E27998479007B6149 /* QuickSearchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB266D27998479007B6149 /* QuickSearchStore.swift */; }; + ABBB266E27998479007B6149 /* QuickSearchReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB266D27998479007B6149 /* QuickSearchReducer.swift */; }; ABBB2671279AFA61007B6149 /* EnvironmentKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2670279AFA61007B6149 /* EnvironmentKeys.swift */; }; ABBB2673279B9332007B6149 /* ReadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2672279B9332007B6149 /* ReadingView.swift */; }; - ABBB2675279B933D007B6149 /* ReadingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2674279B933D007B6149 /* ReadingStore.swift */; }; + ABBB2675279B933D007B6149 /* ReadingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2674279B933D007B6149 /* ReadingReducer.swift */; }; ABBB2677279CDBB0007B6149 /* ImageClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2676279CDBB0007B6149 /* ImageClient.swift */; }; - ABBB2679279D454C007B6149 /* GalleryInfosStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2678279D454C007B6149 /* GalleryInfosStore.swift */; }; + ABBB2679279D454C007B6149 /* GalleryInfosReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2678279D454C007B6149 /* GalleryInfosReducer.swift */; }; ABBC332826BE31AE0084A331 /* EhSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBC332726BE31AE0084A331 /* EhSettingView.swift */; }; ABBC332A26BE7C940084A331 /* SettingTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBC332926BE7C940084A331 /* SettingTextField.swift */; }; ABBCCC9026C95F6E007D8A36 /* GalleryInfosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBCCC8F26C95F6E007D8A36 /* GalleryInfosView.swift */; }; @@ -223,7 +218,7 @@ ABC732C527B9024500D47DA9 /* LiveText.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC732C427B9024500D47DA9 /* LiveText.swift */; }; ABC732C727B90F0900D47DA9 /* LiveTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC732C627B90F0900D47DA9 /* LiveTextView.swift */; }; ABC8355D27B118330091DCDB /* DetailSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8355C27B118330091DCDB /* DetailSearchView.swift */; }; - ABC8355F27B118370091DCDB /* DetailSearchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8355E27B118370091DCDB /* DetailSearchStore.swift */; }; + ABC8355F27B118370091DCDB /* DetailSearchReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8355E27B118370091DCDB /* DetailSearchReducer.swift */; }; ABC8356127B357C50091DCDB /* GestureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8356027B357C50091DCDB /* GestureHandler.swift */; }; ABC8356327B366760091DCDB /* PageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8356227B366760091DCDB /* PageHandler.swift */; }; ABC8356527B36E550091DCDB /* AutoPlayHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC8356427B36E550091DCDB /* AutoPlayHandler.swift */; }; @@ -234,15 +229,13 @@ ABCD2F0E25976B95008E5A20 /* Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD2F0D25976B95008E5A20 /* Parser.swift */; }; ABD4032626B78E5A00001B8C /* GalleryThumbnailCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD4032526B78E5A00001B8C /* GalleryThumbnailCell.swift */; }; ABD4032826B7967F00001B8C /* CategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD4032726B7967F00001B8C /* CategoryView.swift */; }; - ABD49D5A277C5356003D1A07 /* FavoritesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D59277C5356003D1A07 /* FavoritesStore.swift */; }; + ABD49D5A277C5356003D1A07 /* FavoritesReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D59277C5356003D1A07 /* FavoritesReducer.swift */; }; ABD49D5D277C6C9D003D1A07 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = ABD49D5C277C6C9D003D1A07 /* SFSafeSymbols */; }; ABD49D60277C7722003D1A07 /* TabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D5F277C7722003D1A07 /* TabBarView.swift */; }; - ABD49D64277C7AD5003D1A07 /* TabBarStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D63277C7AD5003D1A07 /* TabBarStore.swift */; }; + ABD49D64277C7AD5003D1A07 /* TabBarReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D63277C7AD5003D1A07 /* TabBarReducer.swift */; }; ABD49D67277EAC90003D1A07 /* URLUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D66277EAC90003D1A07 /* URLUtil.swift */; }; - ABD49D6A277EEF73003D1A07 /* SettingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD49D69277EEF73003D1A07 /* SettingStore.swift */; }; ABD5FDD4263D05110021A4C6 /* .swiftlint.yml in Resources */ = {isa = PBXBuildFile; fileRef = ABD5FDD3263D05110021A4C6 /* .swiftlint.yml */; }; ABD7005926B1C31500DC59C9 /* Kanna in Frameworks */ = {isa = PBXBuildFile; productRef = ABD7005826B1C31500DC59C9 /* Kanna */; }; - ABD970B727A2A6BD001693B0 /* Rswift in Frameworks */ = {isa = PBXBuildFile; productRef = ABD970B627A2A6BD001693B0 /* Rswift */; }; ABD9770E27B65A7300983DE7 /* ListParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD9770D27B65A7300983DE7 /* ListParserTests.swift */; }; ABD9771027B65E3400983DE7 /* GalleryDetailParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD9770F27B65E3400983DE7 /* GalleryDetailParserTests.swift */; }; ABD9771327B6612400983DE7 /* GreetingParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD9771227B6612400983DE7 /* GreetingParserTests.swift */; }; @@ -276,6 +269,9 @@ ABF45AF725F3313D00ECB568 /* SettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF45ADD25F3313D00ECB568 /* SettingView.swift */; }; ABF75F3F25A19CD200544D29 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF75F3E25A19CD200544D29 /* User.swift */; }; ABF9720A26DE6E1300118887 /* GalleryDetailWithGreeting.html in Resources */ = {isa = PBXBuildFile; fileRef = ABF9720926DE6E1300118887 /* GalleryDetailWithGreeting.html */; }; + EA2E2E7F2A1F7E500038A261 /* SettingReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2E2E7E2A1F7E500038A261 /* SettingReducer.swift */; }; + EA2E2E822A1FA1060038A261 /* SearchReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2E2E812A1FA1050038A261 /* SearchReducer.swift */; }; + EAE63E2129E2A6330048C601 /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = EAE63E2029E2A6330048C601 /* SwiftyBeaver */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -310,18 +306,18 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - AB0929B5277F043D00F107CA /* AccountSettingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingStore.swift; sourceTree = ""; }; - AB0929BD2780032400F107CA /* EhSettingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSettingStore.swift; sourceTree = ""; }; - AB0929BF27805A8200F107CA /* LoginStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginStore.swift; sourceTree = ""; }; + AB0929B5277F043D00F107CA /* AccountSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingReducer.swift; sourceTree = ""; }; + AB0929BD2780032400F107CA /* EhSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSettingReducer.swift; sourceTree = ""; }; + AB0929BF27805A8200F107CA /* LoginReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginReducer.swift; sourceTree = ""; }; AB0929C5278160AE00F107CA /* LibraryClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryClient.swift; sourceTree = ""; }; AB0929C72781938A00F107CA /* DFClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DFClient.swift; sourceTree = ""; }; - AB0929C9278196ED00F107CA /* CookiesClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookiesClient.swift; sourceTree = ""; }; - AB0929CB2781A0B000F107CA /* HapticClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticClient.swift; sourceTree = ""; }; + AB0929C9278196ED00F107CA /* CookieClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieClient.swift; sourceTree = ""; }; + AB0929CB2781A0B000F107CA /* HapticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticsClient.swift; sourceTree = ""; }; AB0929CD2781AADA00F107CA /* DatabaseClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseClient.swift; sourceTree = ""; }; AB0929CF2781E1CC00F107CA /* UIApplicationClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationClient.swift; sourceTree = ""; }; AB0929D12781E7D500F107CA /* LoggerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerClient.swift; sourceTree = ""; }; AB0929D32781EDDC00F107CA /* UserDefaultsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsClient.swift; sourceTree = ""; }; - AB0929D52782A65F00F107CA /* GeneralSettingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingStore.swift; sourceTree = ""; }; + AB0929D52782A65F00F107CA /* GeneralSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingReducer.swift; sourceTree = ""; }; AB0929D72782A83A00F107CA /* AuthorizationClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationClient.swift; sourceTree = ""; }; AB0ABCB426C5406400AD970F /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; AB0ABCB626C541A400AD970F /* WaveForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveForm.swift; sourceTree = ""; }; @@ -404,8 +400,8 @@ AB48BCF626D2539B0021A06C /* Model 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 2.xcdatamodel"; sourceTree = ""; }; AB4FD2C0268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GalleryDetailMO+CoreDataProperties.swift"; sourceTree = ""; }; AB543FF126DB7FD9009344C0 /* Model 3.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 3.xcdatamodel"; sourceTree = ""; }; - AB58A5AB2776B2BC00C0D285 /* AppDelegateStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateStore.swift; sourceTree = ""; }; - AB58A5B12776B99000C0D285 /* AppStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStore.swift; sourceTree = ""; }; + AB58A5AB2776B2BC00C0D285 /* AppDelegateReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateReducer.swift; sourceTree = ""; }; + AB58A5B12776B99000C0D285 /* AppReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReducer.swift; sourceTree = ""; }; AB5BE67626B95FDD007D4A55 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; AB5BE67826B95FDD007D4A55 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; AB5BE67D26B95FDD007D4A55 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -413,29 +409,28 @@ AB63EADC2699AC9100090535 /* AppEnvMO+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppEnvMO+CoreDataClass.swift"; sourceTree = ""; }; AB69CB7F26B3DABC00699359 /* AdvancedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedList.swift; sourceTree = ""; }; AB69CB8126B3DAF400699359 /* ControlPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlPanel.swift; sourceTree = ""; }; - AB6D106927EBF890003A2245 /* GeneralSettingStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingStoreTests.swift; sourceTree = ""; }; AB6DE896268822390087C579 /* LogsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsView.swift; sourceTree = ""; }; - AB706F7827890A6C0025A48A /* AppRouteStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteStore.swift; sourceTree = ""; }; - AB706F7A278937500025A48A /* FrontpageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontpageStore.swift; sourceTree = ""; }; + AB706F7827890A6C0025A48A /* AppRouteReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouteReducer.swift; sourceTree = ""; }; + AB706F7A278937500025A48A /* FrontpageReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontpageReducer.swift; sourceTree = ""; }; AB706F7F278981370025A48A /* AlertKit_Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertKit_Extension.swift; sourceTree = ""; }; AB706F81278986120025A48A /* ToolbarItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarItems.swift; sourceTree = ""; }; AB706F832789AD2D0025A48A /* ToplistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToplistsView.swift; sourceTree = ""; }; - AB706F852789AD490025A48A /* ToplistsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToplistsStore.swift; sourceTree = ""; }; + AB706F852789AD490025A48A /* ToplistsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToplistsReducer.swift; sourceTree = ""; }; AB706F87278A4C8A0025A48A /* PopularView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularView.swift; sourceTree = ""; }; - AB706F89278A4CC50025A48A /* PopularStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularStore.swift; sourceTree = ""; }; + AB706F89278A4CC50025A48A /* PopularReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopularReducer.swift; sourceTree = ""; }; AB706F8B278A4F6C0025A48A /* WatchedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchedView.swift; sourceTree = ""; }; AB706F8D278A5DCF0025A48A /* DeviceClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceClient.swift; sourceTree = ""; }; AB706F8F278A5F680025A48A /* AppDelegateClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateClient.swift; sourceTree = ""; }; - AB706F91278A6E8C0025A48A /* WatchedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchedStore.swift; sourceTree = ""; }; + AB706F91278A6E8C0025A48A /* WatchedReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchedReducer.swift; sourceTree = ""; }; AB706F93278A6F2B0025A48A /* Model 6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 6.xcdatamodel"; sourceTree = ""; }; AB706F94278A75D30025A48A /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; - AB706F96278A77E20025A48A /* HistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryStore.swift; sourceTree = ""; }; - AB706F98278A820C0025A48A /* FiltersStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersStore.swift; sourceTree = ""; }; + AB706F96278A77E20025A48A /* HistoryReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryReducer.swift; sourceTree = ""; }; + AB706F98278A820C0025A48A /* FiltersReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersReducer.swift; sourceTree = ""; }; AB706F9A278AC5A30025A48A /* SearchRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRootView.swift; sourceTree = ""; }; - AB706F9C278ACCA20025A48A /* SearchRootStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRootStore.swift; sourceTree = ""; }; + AB706F9C278ACCA20025A48A /* SearchRootReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRootReducer.swift; sourceTree = ""; }; AB706F9E278AD4800025A48A /* GalleryHistoryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryHistoryCell.swift; sourceTree = ""; }; AB706FA0278BCEC60025A48A /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = ""; }; - AB706FA2278BCF2F0025A48A /* DetailStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailStore.swift; sourceTree = ""; }; + AB706FA2278BCF2F0025A48A /* DetailReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailReducer.swift; sourceTree = ""; }; AB706FA4278C3DDE0025A48A /* PreviewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewsView.swift; sourceTree = ""; }; AB7B29F126AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Model5toModel6MigrationPolicy.swift; sourceTree = ""; }; AB7B29F526AC741600EE1F14 /* GenericList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericList.swift; sourceTree = ""; }; @@ -453,13 +448,13 @@ AB7BF2CB27A96A3C001865A3 /* GalleryTorrent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryTorrent.swift; sourceTree = ""; }; AB7BF2CD27AA3E58001865A3 /* AppUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUtil.swift; sourceTree = ""; }; AB7BF2CF27AA3E75001865A3 /* DeviceUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceUtil.swift; sourceTree = ""; }; - AB7BF2D127AA3EDC001865A3 /* HapticUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticUtil.swift; sourceTree = ""; }; - AB7BF2D327AA3F12001865A3 /* CookiesUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookiesUtil.swift; sourceTree = ""; }; + AB7BF2D127AA3EDC001865A3 /* HapticsUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticsUtil.swift; sourceTree = ""; }; + AB7BF2D327AA3F12001865A3 /* CookieUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieUtil.swift; sourceTree = ""; }; AB7BF2D527AA3F4C001865A3 /* FileUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtil.swift; sourceTree = ""; }; AB7BF2D727AA3F61001865A3 /* UserDefaultsUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsUtil.swift; sourceTree = ""; }; AB7BF2D927AA78CF001865A3 /* Reducer_Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reducer_Extension.swift; sourceTree = ""; }; AB7BF2FA27ABCA3A001865A3 /* MigrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationView.swift; sourceTree = ""; }; - AB7BF2FC27ABCAD4001865A3 /* MigrationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationStore.swift; sourceTree = ""; }; + AB7BF2FC27ABCAD4001865A3 /* MigrationReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationReducer.swift; sourceTree = ""; }; AB7BF2FE27ABDFF1001865A3 /* CoreDataMigrator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataMigrator.swift; sourceTree = ""; }; AB7BF30327ABDFF1001865A3 /* CoreDataMigrationStep.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataMigrationStep.swift; sourceTree = ""; }; AB7BF30627ABDFF1001865A3 /* CoreDataMigrationVersion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreDataMigrationVersion.swift; sourceTree = ""; }; @@ -470,12 +465,12 @@ AB7E6B3125D24FE00035CC68 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; AB7E6B3425D24FE40035CC68 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; AB7E6B3525D24FE50035CC68 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; - AB86ABF42782DAB300E61E6A /* LogsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsStore.swift; sourceTree = ""; }; + AB86ABF42782DAB300E61E6A /* LogsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsReducer.swift; sourceTree = ""; }; AB86ABF62782DDE600E61E6A /* FileClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileClient.swift; sourceTree = ""; }; - AB86ABF82782EC0D00E61E6A /* EhPandaView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EhPandaView.swift; sourceTree = ""; }; - AB86AC092782FAFA00E61E6A /* AppearanceSettingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingStore.swift; sourceTree = ""; }; - AB86AC1227856F2700E61E6A /* AppLockStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockStore.swift; sourceTree = ""; }; - AB86AC192785C2B300E61E6A /* HomeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeStore.swift; sourceTree = ""; }; + AB86ABF82782EC0D00E61E6A /* AboutView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; + AB86AC092782FAFA00E61E6A /* AppearanceSettingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingReducer.swift; sourceTree = ""; }; + AB86AC1227856F2700E61E6A /* AppLockReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockReducer.swift; sourceTree = ""; }; + AB86AC192785C2B300E61E6A /* HomeReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeReducer.swift; sourceTree = ""; }; AB8C821826BF801700E8C5E6 /* EhSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSetting.swift; sourceTree = ""; }; AB902766291F548600697256 /* AppIcon_NotMyPresident@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_NotMyPresident@3x.png"; sourceTree = ""; }; AB902767291F548600697256 /* AppIcon_NotMyPresident_iPad@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_NotMyPresident_iPad@2x.png"; sourceTree = ""; }; @@ -483,33 +478,30 @@ AB902769291F548700697256 /* AppIcon_NotMyPresident_iPad.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = AppIcon_NotMyPresident_iPad.png; sourceTree = ""; }; AB90276A291F548700697256 /* AppIcon_NotMyPresident@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon_NotMyPresident@2x.png"; sourceTree = ""; }; AB994DBB25986F7A00E9A367 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; - ABA12F3127D49CEB0021922D /* AccountSettingStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingStoreTests.swift; sourceTree = ""; }; ABA732D825A8018A00B3D9AB /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; ABA732DE25A852D800B3D9AB /* Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filter.swift; sourceTree = ""; }; ABA9A6BB28EC786100EE28DE /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = SOURCE_ROOT; }; ABA9A6BE28EC7BA200EE28DE /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Constant.strings; sourceTree = ""; }; ABA9A6C128EC7BD000EE28DE /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; ABAB5B9427EF023300198597 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; - ABAB5B9627EF03F600198597 /* AppearanceSettingStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingStoreTests.swift; sourceTree = ""; }; ABB5013026A41EBA00B542D9 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; ABB5013126A41EBA00B542D9 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = ""; }; ABBB2630278E6EF3007B6149 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; - ABBB2632278E6F3B007B6149 /* SearchStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchStore.swift; sourceTree = ""; }; ABBB2637278FBD2F007B6149 /* SwiftUINavigation_Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUINavigation_Extension.swift; sourceTree = ""; }; ABBB26392792588F007B6149 /* TTProgressHUD_Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTProgressHUD_Extension.swift; sourceTree = ""; }; - ABBB263D2793C648007B6149 /* PreviewsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewsStore.swift; sourceTree = ""; }; - ABBB263F279417EC007B6149 /* CommentsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsStore.swift; sourceTree = ""; }; + ABBB263D2793C648007B6149 /* PreviewsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewsReducer.swift; sourceTree = ""; }; + ABBB263F279417EC007B6149 /* CommentsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsReducer.swift; sourceTree = ""; }; ABBB264127942B74007B6149 /* URLClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLClient.swift; sourceTree = ""; }; - ABBB266527977C2A007B6149 /* ArchivesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchivesStore.swift; sourceTree = ""; }; + ABBB266527977C2A007B6149 /* ArchivesReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchivesReducer.swift; sourceTree = ""; }; ABBB26672797BFAA007B6149 /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = ""; }; - ABBB26692797C61F007B6149 /* TorrentsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorrentsStore.swift; sourceTree = ""; }; + ABBB26692797C61F007B6149 /* TorrentsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorrentsReducer.swift; sourceTree = ""; }; ABBB266B2797E882007B6149 /* ClipboardClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardClient.swift; sourceTree = ""; }; - ABBB266D27998479007B6149 /* QuickSearchStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSearchStore.swift; sourceTree = ""; }; + ABBB266D27998479007B6149 /* QuickSearchReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickSearchReducer.swift; sourceTree = ""; }; ABBB2670279AFA61007B6149 /* EnvironmentKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentKeys.swift; sourceTree = ""; }; ABBB2672279B9332007B6149 /* ReadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingView.swift; sourceTree = ""; }; - ABBB2674279B933D007B6149 /* ReadingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingStore.swift; sourceTree = ""; }; + ABBB2674279B933D007B6149 /* ReadingReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingReducer.swift; sourceTree = ""; }; ABBB2676279CDBB0007B6149 /* ImageClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageClient.swift; sourceTree = ""; }; - ABBB2678279D454C007B6149 /* GalleryInfosStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryInfosStore.swift; sourceTree = ""; }; + ABBB2678279D454C007B6149 /* GalleryInfosReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryInfosReducer.swift; sourceTree = ""; }; ABBC332726BE31AE0084A331 /* EhSettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EhSettingView.swift; sourceTree = ""; }; ABBC332926BE7C940084A331 /* SettingTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingTextField.swift; sourceTree = ""; }; ABBCCC8F26C95F6E007D8A36 /* GalleryInfosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryInfosView.swift; sourceTree = ""; }; @@ -528,7 +520,7 @@ ABC732C427B9024500D47DA9 /* LiveText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveText.swift; sourceTree = ""; }; ABC732C627B90F0900D47DA9 /* LiveTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTextView.swift; sourceTree = ""; }; ABC8355C27B118330091DCDB /* DetailSearchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailSearchView.swift; sourceTree = ""; }; - ABC8355E27B118370091DCDB /* DetailSearchStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailSearchStore.swift; sourceTree = ""; }; + ABC8355E27B118370091DCDB /* DetailSearchReducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailSearchReducer.swift; sourceTree = ""; }; ABC8356027B357C50091DCDB /* GestureHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureHandler.swift; sourceTree = ""; }; ABC8356227B366760091DCDB /* PageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHandler.swift; sourceTree = ""; }; ABC8356427B36E550091DCDB /* AutoPlayHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoPlayHandler.swift; sourceTree = ""; }; @@ -539,11 +531,10 @@ ABCD2F0D25976B95008E5A20 /* Parser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parser.swift; sourceTree = ""; }; ABD4032526B78E5A00001B8C /* GalleryThumbnailCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryThumbnailCell.swift; sourceTree = ""; }; ABD4032726B7967F00001B8C /* CategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryView.swift; sourceTree = ""; }; - ABD49D59277C5356003D1A07 /* FavoritesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesStore.swift; sourceTree = ""; }; + ABD49D59277C5356003D1A07 /* FavoritesReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesReducer.swift; sourceTree = ""; }; ABD49D5F277C7722003D1A07 /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = ""; }; - ABD49D63277C7AD5003D1A07 /* TabBarStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarStore.swift; sourceTree = ""; }; + ABD49D63277C7AD5003D1A07 /* TabBarReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarReducer.swift; sourceTree = ""; }; ABD49D66277EAC90003D1A07 /* URLUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLUtil.swift; sourceTree = ""; }; - ABD49D69277EEF73003D1A07 /* SettingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingStore.swift; sourceTree = ""; }; ABD5FDD3263D05110021A4C6 /* .swiftlint.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = SOURCE_ROOT; }; ABD9770D27B65A7300983DE7 /* ListParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListParserTests.swift; sourceTree = ""; }; ABD9770F27B65E3400983DE7 /* GalleryDetailParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryDetailParserTests.swift; sourceTree = ""; }; @@ -588,6 +579,8 @@ ABF53F4725A306D200AB5918 /* EhPanda.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = EhPanda.entitlements; sourceTree = ""; }; ABF75F3E25A19CD200544D29 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; ABF9720926DE6E1300118887 /* GalleryDetailWithGreeting.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = GalleryDetailWithGreeting.html; sourceTree = ""; }; + EA2E2E7E2A1F7E500038A261 /* SettingReducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingReducer.swift; sourceTree = ""; }; + EA2E2E812A1FA1050038A261 /* SearchReducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchReducer.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -603,17 +596,16 @@ buildActionMask = 2147483647; files = ( AB2EB99F280251D600011A8A /* TTProgressHUD in Frameworks */, - ABD970B727A2A6BD001693B0 /* Rswift in Frameworks */, AB2EB9A52802521700011A8A /* DeprecatedAPI in Frameworks */, AB17574027678B3400FD64E2 /* UIImageColors in Frameworks */, AB2EB9A2280251F600011A8A /* AlertKit in Frameworks */, ABD7005926B1C31500DC59C9 /* Kanna in Frameworks */, AB60D0E9274C7ECE00F899AB /* WaterfallGrid in Frameworks */, ABC4A0792751B40E00968A4F /* Kingfisher in Frameworks */, + EAE63E2129E2A6330048C601 /* SwiftyBeaver in Frameworks */, AB26F59927ACDB4200AB3468 /* FilePicker in Frameworks */, AB6505A026B0027800F91E9D /* SwiftUIPager in Frameworks */, ABD49D5D277C6C9D003D1A07 /* SFSafeSymbols in Frameworks */, - AB2EB9AB2802525700011A8A /* SwiftyBeaver in Frameworks */, ABAC82FE26BC4A96009F5026 /* OpenCC in Frameworks */, AB86AC1027831AD100E61E6A /* ComposableArchitecture in Frameworks */, ABBB2636278FB888007B6149 /* SwiftUINavigation in Frameworks */, @@ -632,20 +624,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - AB0929BA277F1B7400F107CA /* DataFlow */ = { - isa = PBXGroup; - children = ( - ABD49D69277EEF73003D1A07 /* SettingStore.swift */, - AB0929B5277F043D00F107CA /* AccountSettingStore.swift */, - AB0929D52782A65F00F107CA /* GeneralSettingStore.swift */, - AB86AC092782FAFA00E61E6A /* AppearanceSettingStore.swift */, - AB0929BD2780032400F107CA /* EhSettingStore.swift */, - AB0929BF27805A8200F107CA /* LoginStore.swift */, - AB86ABF42782DAB300E61E6A /* LogsStore.swift */, - ); - path = DataFlow; - sourceTree = ""; - }; AB0929C12781589000F107CA /* Clients */ = { isa = PBXGroup; children = ( @@ -655,9 +633,9 @@ ABBB2676279CDBB0007B6149 /* ImageClient.swift */, AB706F8D278A5DCF0025A48A /* DeviceClient.swift */, AB0929D12781E7D500F107CA /* LoggerClient.swift */, - AB0929CB2781A0B000F107CA /* HapticClient.swift */, + AB0929CB2781A0B000F107CA /* HapticsClient.swift */, AB0929C5278160AE00F107CA /* LibraryClient.swift */, - AB0929C9278196ED00F107CA /* CookiesClient.swift */, + AB0929C9278196ED00F107CA /* CookieClient.swift */, AB0929CD2781AADA00F107CA /* DatabaseClient.swift */, ABBB266B2797E882007B6149 /* ClipboardClient.swift */, AB706F8F278A5F680025A48A /* AppDelegateClient.swift */, @@ -686,7 +664,7 @@ isa = PBXGroup; children = ( ABF45AC125F3313D00ECB568 /* FiltersView.swift */, - AB706F98278A820C0025A48A /* FiltersStore.swift */, + AB706F98278A820C0025A48A /* FiltersReducer.swift */, ABC1FAB72642C37D00A9F352 /* NewDawnView.swift */, ABF45AC625F3313D00ECB568 /* Components */, ); @@ -697,22 +675,11 @@ isa = PBXGroup; children = ( AB24C55927674EDF0085C33A /* FavoritesView.swift */, - ABD49D59277C5356003D1A07 /* FavoritesStore.swift */, + ABD49D59277C5356003D1A07 /* FavoritesReducer.swift */, ); path = Favorites; sourceTree = ""; }; - AB24C560276757940085C33A /* Support */ = { - isa = PBXGroup; - children = ( - ABF45ADA25F3313D00ECB568 /* WebView.swift */, - AB6DE896268822390087C579 /* LogsView.swift */, - ABBC332726BE31AE0084A331 /* EhSettingView.swift */, - AB0ABCB426C5406400AD970F /* LoginView.swift */, - ); - path = Support; - sourceTree = ""; - }; AB24C561276757A30085C33A /* Support */ = { isa = PBXGroup; children = ( @@ -727,7 +694,7 @@ path = Support; sourceTree = ""; }; - AB24C562276757B00085C33A /* Support */ = { + AB24C562276757B00085C33A /* Components */ = { isa = PBXGroup; children = ( ABF45ACB25F3313D00ECB568 /* LinkedText.swift */, @@ -735,14 +702,14 @@ AB0CFBD627C3B2D0004BD372 /* TagDetailView.swift */, ABF45AC825F3313D00ECB568 /* PostCommentView.swift */, ); - path = Support; + path = Components; sourceTree = ""; }; AB24C563276757C30085C33A /* Support */ = { isa = PBXGroup; children = ( ABE9401426FF158D0085E158 /* QuickSearchView.swift */, - ABBB266D27998479007B6149 /* QuickSearchStore.swift */, + ABBB266D27998479007B6149 /* QuickSearchReducer.swift */, ); path = Support; sourceTree = ""; @@ -928,19 +895,6 @@ path = ShareExtension; sourceTree = ""; }; - AB706F7D278937A70025A48A /* DataFlow */ = { - isa = PBXGroup; - children = ( - AB86AC192785C2B300E61E6A /* HomeStore.swift */, - AB706F7A278937500025A48A /* FrontpageStore.swift */, - AB706F852789AD490025A48A /* ToplistsStore.swift */, - AB706F89278A4CC50025A48A /* PopularStore.swift */, - AB706F91278A6E8C0025A48A /* WatchedStore.swift */, - AB706F96278A77E20025A48A /* HistoryStore.swift */, - ); - path = DataFlow; - sourceTree = ""; - }; AB706F7E278981210025A48A /* Extensions */ = { isa = PBXGroup; children = ( @@ -1024,7 +978,7 @@ isa = PBXGroup; children = ( AB7BF2FA27ABCA3A001865A3 /* MigrationView.swift */, - AB7BF2FC27ABCAD4001865A3 /* MigrationStore.swift */, + AB7BF2FC27ABCAD4001865A3 /* MigrationReducer.swift */, ); path = Migration; sourceTree = ""; @@ -1103,9 +1057,9 @@ isa = PBXGroup; children = ( AB706F9A278AC5A30025A48A /* SearchRootView.swift */, - AB706F9C278ACCA20025A48A /* SearchRootStore.swift */, + AB706F9C278ACCA20025A48A /* SearchRootReducer.swift */, ABBB2630278E6EF3007B6149 /* SearchView.swift */, - ABBB2632278E6F3B007B6149 /* SearchStore.swift */, + EA2E2E812A1FA1050038A261 /* SearchReducer.swift */, AB24C563276757C30085C33A /* Support */, ); path = Search; @@ -1114,30 +1068,11 @@ ABA12F2E27D49AD10021922D /* Tests */ = { isa = PBXGroup; children = ( - ABA12F2F27D49B610021922D /* Store */, ABD9770C27B65A5300983DE7 /* Parser */, ); path = Tests; sourceTree = ""; }; - ABA12F2F27D49B610021922D /* Store */ = { - isa = PBXGroup; - children = ( - ABA12F3027D49CDA0021922D /* Setting */, - ); - path = Store; - sourceTree = ""; - }; - ABA12F3027D49CDA0021922D /* Setting */ = { - isa = PBXGroup; - children = ( - ABA12F3127D49CEB0021922D /* AccountSettingStoreTests.swift */, - AB6D106927EBF890003A2245 /* GeneralSettingStoreTests.swift */, - ABAB5B9627EF03F600198597 /* AppearanceSettingStoreTests.swift */, - ); - path = Setting; - sourceTree = ""; - }; ABA9A6C028EC7BD000EE28DE /* Generated */ = { isa = PBXGroup; children = ( @@ -1146,20 +1081,6 @@ path = Generated; sourceTree = ""; }; - ABBB263C2793C580007B6149 /* DataFlow */ = { - isa = PBXGroup; - children = ( - AB706FA2278BCF2F0025A48A /* DetailStore.swift */, - ABC8355E27B118370091DCDB /* DetailSearchStore.swift */, - ABBB266527977C2A007B6149 /* ArchivesStore.swift */, - ABBB26692797C61F007B6149 /* TorrentsStore.swift */, - ABBB263D2793C648007B6149 /* PreviewsStore.swift */, - ABBB263F279417EC007B6149 /* CommentsStore.swift */, - ABBB2678279D454C007B6149 /* GalleryInfosStore.swift */, - ); - path = DataFlow; - sourceTree = ""; - }; ABC3C74B2593696C00E0C11B = { isa = PBXGroup; children = ( @@ -1241,7 +1162,7 @@ isa = PBXGroup; children = ( ABD49D5F277C7722003D1A07 /* TabBarView.swift */, - ABD49D63277C7AD5003D1A07 /* TabBarStore.swift */, + ABD49D63277C7AD5003D1A07 /* TabBarReducer.swift */, ); path = TabBar; sourceTree = ""; @@ -1253,8 +1174,8 @@ AB7BF2CD27AA3E58001865A3 /* AppUtil.swift */, AB7BF2D527AA3F4C001865A3 /* FileUtil.swift */, AB7BF2CF27AA3E75001865A3 /* DeviceUtil.swift */, - AB7BF2D127AA3EDC001865A3 /* HapticUtil.swift */, - AB7BF2D327AA3F12001865A3 /* CookiesUtil.swift */, + AB7BF2D127AA3EDC001865A3 /* HapticsUtil.swift */, + AB7BF2D327AA3F12001865A3 /* CookieUtil.swift */, AB0CFBD427C24B3B004BD372 /* MarkdownUtil.swift */, AB7BF2D727AA3F61001865A3 /* UserDefaultsUtil.swift */, ); @@ -1285,10 +1206,10 @@ isa = PBXGroup; children = ( AB1EF25327AFA19200F507D6 /* Heap.swift */, - AB58A5B12776B99000C0D285 /* AppStore.swift */, - AB86AC1227856F2700E61E6A /* AppLockStore.swift */, - AB706F7827890A6C0025A48A /* AppRouteStore.swift */, - AB58A5AB2776B2BC00C0D285 /* AppDelegateStore.swift */, + AB58A5B12776B99000C0D285 /* AppReducer.swift */, + AB86AC1227856F2700E61E6A /* AppLockReducer.swift */, + AB706F7827890A6C0025A48A /* AppRouteReducer.swift */, + AB58A5AB2776B2BC00C0D285 /* AppDelegateReducer.swift */, ); path = DataFlow; sourceTree = ""; @@ -1312,13 +1233,13 @@ ABF45AC025F3313D00ECB568 /* Home */ = { isa = PBXGroup; children = ( + EA7E47EC2A210C4300971697 /* History */, + EA7E47EA2A2103FE00971697 /* Popular */, + EA7E47E92A2102BA00971697 /* Toplists */, + EA7E47EB2A2107CF00971697 /* Watched */, + EA7E47E82A21015400971697 /* Frontpage */, AB24C55B2767565A0085C33A /* HomeView.swift */, - AB3072D3276E19AA00EFF242 /* FrontpageView.swift */, - AB706F832789AD2D0025A48A /* ToplistsView.swift */, - AB706F87278A4C8A0025A48A /* PopularView.swift */, - AB706F8B278A4F6C0025A48A /* WatchedView.swift */, - AB706F94278A75D30025A48A /* HistoryView.swift */, - AB706F7D278937A70025A48A /* DataFlow */, + AB86AC192785C2B300E61E6A /* HomeReducer.swift */, ); path = Home; sourceTree = ""; @@ -1346,7 +1267,7 @@ isa = PBXGroup; children = ( ABBB2672279B9332007B6149 /* ReadingView.swift */, - ABBB2674279B933D007B6149 /* ReadingStore.swift */, + ABBB2674279B933D007B6149 /* ReadingReducer.swift */, AB24C561276757A30085C33A /* Support */, ); path = Reading; @@ -1355,15 +1276,15 @@ ABF45AD125F3313D00ECB568 /* Detail */ = { isa = PBXGroup; children = ( + EA2E2E852A20E40B0038A261 /* Torrents */, + EA2E2E842A20E1840038A261 /* Archives */, + EA2E2E862A20E52C0038A261 /* Previews */, + EA2E2E872A20E6C90038A261 /* Comments */, + EA2E2E882A20E9C50038A261 /* GalleryInfos */, + EA2E2E892A20EA460038A261 /* DetailSearch */, + AB24C562276757B00085C33A /* Components */, AB706FA0278BCEC60025A48A /* DetailView.swift */, - ABC8355C27B118330091DCDB /* DetailSearchView.swift */, - ABF45AD325F3313D00ECB568 /* ArchivesView.swift */, - ABF45AD425F3313D00ECB568 /* TorrentsView.swift */, - AB706FA4278C3DDE0025A48A /* PreviewsView.swift */, - ABF45AD525F3313D00ECB568 /* CommentsView.swift */, - ABBCCC8F26C95F6E007D8A36 /* GalleryInfosView.swift */, - ABBB263C2793C580007B6149 /* DataFlow */, - AB24C562276757B00085C33A /* Support */, + AB706FA2278BCF2F0025A48A /* DetailReducer.swift */, ); path = Detail; sourceTree = ""; @@ -1371,17 +1292,181 @@ ABF45AD725F3313D00ECB568 /* Setting */ = { isa = PBXGroup; children = ( + EA2E2E792A1F78980038A261 /* Logs */, + EA2B9B062A0A8A7C00E7BA07 /* Login */, + EAEC870B2A1F74D500E1A97A /* EhSetting */, + EA2E2E7B2A1F7AEF0038A261 /* GeneralSetting */, + EA2B9B042A0A89C900E7BA07 /* AccountSetting */, + EA2E2E7D2A1F7D390038A261 /* AppearanceSetting */, + EA2E2E802A1F7F2A0038A261 /* Components */, ABF45ADD25F3313D00ECB568 /* SettingView.swift */, + EA2E2E7E2A1F7E500038A261 /* SettingReducer.swift */, + ); + path = Setting; + sourceTree = ""; + }; + EA2B9B042A0A89C900E7BA07 /* AccountSetting */ = { + isa = PBXGroup; + children = ( ABF45AD925F3313D00ECB568 /* AccountSettingView.swift */, + AB0929B5277F043D00F107CA /* AccountSettingReducer.swift */, + ); + path = AccountSetting; + sourceTree = ""; + }; + EA2B9B062A0A8A7C00E7BA07 /* Login */ = { + isa = PBXGroup; + children = ( + AB0ABCB426C5406400AD970F /* LoginView.swift */, + AB0929BF27805A8200F107CA /* LoginReducer.swift */, + ); + path = Login; + sourceTree = ""; + }; + EA2E2E792A1F78980038A261 /* Logs */ = { + isa = PBXGroup; + children = ( + AB6DE896268822390087C579 /* LogsView.swift */, + AB86ABF42782DAB300E61E6A /* LogsReducer.swift */, + ); + path = Logs; + sourceTree = ""; + }; + EA2E2E7B2A1F7AEF0038A261 /* GeneralSetting */ = { + isa = PBXGroup; + children = ( ABF45AD825F3313D00ECB568 /* GeneralSettingView.swift */, + AB0929D52782A65F00F107CA /* GeneralSettingReducer.swift */, + ); + path = GeneralSetting; + sourceTree = ""; + }; + EA2E2E7D2A1F7D390038A261 /* AppearanceSetting */ = { + isa = PBXGroup; + children = ( ABF45ADC25F3313D00ECB568 /* AppearanceSettingView.swift */, + AB86AC092782FAFA00E61E6A /* AppearanceSettingReducer.swift */, + ); + path = AppearanceSetting; + sourceTree = ""; + }; + EA2E2E802A1F7F2A0038A261 /* Components */ = { + isa = PBXGroup; + children = ( ABF45ADB25F3313D00ECB568 /* ReadingSettingView.swift */, ABE1867726A1733000689FDC /* LaboratorySettingView.swift */, - AB86ABF82782EC0D00E61E6A /* EhPandaView.swift */, - AB0929BA277F1B7400F107CA /* DataFlow */, - AB24C560276757940085C33A /* Support */, + AB86ABF82782EC0D00E61E6A /* AboutView.swift */, + ABF45ADA25F3313D00ECB568 /* WebView.swift */, ); - path = Setting; + path = Components; + sourceTree = ""; + }; + EA2E2E842A20E1840038A261 /* Archives */ = { + isa = PBXGroup; + children = ( + ABF45AD325F3313D00ECB568 /* ArchivesView.swift */, + ABBB266527977C2A007B6149 /* ArchivesReducer.swift */, + ); + path = Archives; + sourceTree = ""; + }; + EA2E2E852A20E40B0038A261 /* Torrents */ = { + isa = PBXGroup; + children = ( + ABF45AD425F3313D00ECB568 /* TorrentsView.swift */, + ABBB26692797C61F007B6149 /* TorrentsReducer.swift */, + ); + path = Torrents; + sourceTree = ""; + }; + EA2E2E862A20E52C0038A261 /* Previews */ = { + isa = PBXGroup; + children = ( + AB706FA4278C3DDE0025A48A /* PreviewsView.swift */, + ABBB263D2793C648007B6149 /* PreviewsReducer.swift */, + ); + path = Previews; + sourceTree = ""; + }; + EA2E2E872A20E6C90038A261 /* Comments */ = { + isa = PBXGroup; + children = ( + ABF45AD525F3313D00ECB568 /* CommentsView.swift */, + ABBB263F279417EC007B6149 /* CommentsReducer.swift */, + ); + path = Comments; + sourceTree = ""; + }; + EA2E2E882A20E9C50038A261 /* GalleryInfos */ = { + isa = PBXGroup; + children = ( + ABBCCC8F26C95F6E007D8A36 /* GalleryInfosView.swift */, + ABBB2678279D454C007B6149 /* GalleryInfosReducer.swift */, + ); + path = GalleryInfos; + sourceTree = ""; + }; + EA2E2E892A20EA460038A261 /* DetailSearch */ = { + isa = PBXGroup; + children = ( + ABC8355C27B118330091DCDB /* DetailSearchView.swift */, + ABC8355E27B118370091DCDB /* DetailSearchReducer.swift */, + ); + path = DetailSearch; + sourceTree = ""; + }; + EA7E47E82A21015400971697 /* Frontpage */ = { + isa = PBXGroup; + children = ( + AB3072D3276E19AA00EFF242 /* FrontpageView.swift */, + AB706F7A278937500025A48A /* FrontpageReducer.swift */, + ); + path = Frontpage; + sourceTree = ""; + }; + EA7E47E92A2102BA00971697 /* Toplists */ = { + isa = PBXGroup; + children = ( + AB706F832789AD2D0025A48A /* ToplistsView.swift */, + AB706F852789AD490025A48A /* ToplistsReducer.swift */, + ); + path = Toplists; + sourceTree = ""; + }; + EA7E47EA2A2103FE00971697 /* Popular */ = { + isa = PBXGroup; + children = ( + AB706F87278A4C8A0025A48A /* PopularView.swift */, + AB706F89278A4CC50025A48A /* PopularReducer.swift */, + ); + path = Popular; + sourceTree = ""; + }; + EA7E47EB2A2107CF00971697 /* Watched */ = { + isa = PBXGroup; + children = ( + AB706F8B278A4F6C0025A48A /* WatchedView.swift */, + AB706F91278A6E8C0025A48A /* WatchedReducer.swift */, + ); + path = Watched; + sourceTree = ""; + }; + EA7E47EC2A210C4300971697 /* History */ = { + isa = PBXGroup; + children = ( + AB706F94278A75D30025A48A /* HistoryView.swift */, + AB706F96278A77E20025A48A /* HistoryReducer.swift */, + ); + path = History; + sourceTree = ""; + }; + EAEC870B2A1F74D500E1A97A /* EhSetting */ = { + isa = PBXGroup; + children = ( + ABBC332726BE31AE0084A331 /* EhSettingView.swift */, + AB0929BD2780032400F107CA /* EhSettingReducer.swift */, + ); + path = EhSetting; sourceTree = ""; }; /* End PBXGroup section */ @@ -1432,13 +1517,12 @@ ABD49D5C277C6C9D003D1A07 /* SFSafeSymbols */, AB86AC0F27831AD100E61E6A /* ComposableArchitecture */, ABBB2635278FB888007B6149 /* SwiftUINavigation */, - ABD970B627A2A6BD001693B0 /* Rswift */, AB26F59827ACDB4200AB3468 /* FilePicker */, AB1FA94827C62BC80063EF55 /* CommonMark */, AB2EB99E280251D600011A8A /* TTProgressHUD */, AB2EB9A1280251F600011A8A /* AlertKit */, AB2EB9A42802521700011A8A /* DeprecatedAPI */, - AB2EB9AA2802525700011A8A /* SwiftyBeaver */, + EAE63E2029E2A6330048C601 /* SwiftyBeaver */, ); productName = EhPanda; productReference = ABC3C7542593696C00E0C11B /* EhPanda.app */; @@ -1468,8 +1552,9 @@ ABC3C74C2593696C00E0C11B /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1300; - LastUpgradeCheck = 1400; + LastUpgradeCheck = 1430; TargetAttributes = { AB5BE67526B95FDD007D4A55 = { CreatedOnToolsVersion = 13.0; @@ -1510,13 +1595,12 @@ ABD49D5B277C6C9D003D1A07 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, AB86AC0E27831AD100E61E6A /* XCRemoteSwiftPackageReference "swift-composable-architecture" */, ABBB2634278FB888007B6149 /* XCRemoteSwiftPackageReference "swiftui-navigation" */, - ABD970B527A2A6BD001693B0 /* XCRemoteSwiftPackageReference "R.swift.Library" */, AB26F59727ACDB4200AB3468 /* XCRemoteSwiftPackageReference "FilePicker" */, AB1FA94727C62BC80063EF55 /* XCRemoteSwiftPackageReference "SwiftCommonMark" */, AB2EB99D280251D600011A8A /* XCRemoteSwiftPackageReference "TTProgressHUD" */, AB2EB9A0280251F600011A8A /* XCRemoteSwiftPackageReference "AlertKit" */, AB2EB9A32802521700011A8A /* XCRemoteSwiftPackageReference "DeprecatedAPI" */, - AB2EB9A92802525700011A8A /* XCRemoteSwiftPackageReference "SwiftyBeaver" */, + EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */, ); productRefGroup = ABC3C7552593696C00E0C11B /* Products */; projectDirPath = ""; @@ -1674,30 +1758,32 @@ ABF45ABB25F3312F00ECB568 /* AppError.swift in Sources */, AB10118026986C1100C2C1A9 /* GalleryStateMO+CoreDataClass.swift in Sources */, AB63EADB2699AC8200090535 /* AppEnvMO+CoreDataProperties.swift in Sources */, - AB7BF2FD27ABCAD4001865A3 /* MigrationStore.swift in Sources */, + AB7BF2FD27ABCAD4001865A3 /* MigrationReducer.swift in Sources */, AB7BF2D827AA3F61001865A3 /* UserDefaultsUtil.swift in Sources */, - AB0929CA278196ED00F107CA /* CookiesClient.swift in Sources */, + EA2E2E822A1FA1060038A261 /* SearchReducer.swift in Sources */, + AB0929CA278196ED00F107CA /* CookieClient.swift in Sources */, AB7BF2FB27ABCA3A001865A3 /* MigrationView.swift in Sources */, AB7BF31C27ABE028001865A3 /* NSManagedObjectModel+Compatible.swift in Sources */, + EA2E2E7F2A1F7E500038A261 /* SettingReducer.swift in Sources */, AB7BF31B27ABE028001865A3 /* NSManagedObjectModel+Resource.swift in Sources */, ABBC332826BE31AE0084A331 /* EhSettingView.swift in Sources */, AB7BF2C827A968F7001865A3 /* GalleryComment.swift in Sources */, - AB706F97278A77E20025A48A /* HistoryStore.swift in Sources */, - AB0929CC2781A0B000F107CA /* HapticClient.swift in Sources */, + AB706F97278A77E20025A48A /* HistoryReducer.swift in Sources */, + AB0929CC2781A0B000F107CA /* HapticsClient.swift in Sources */, ABD4032626B78E5A00001B8C /* GalleryThumbnailCell.swift in Sources */, AB0CFBCD27C1CC67004BD372 /* EhTagTranslationDatabaseModel.swift in Sources */, AB358319269D9996009466A5 /* DomainResolver.swift in Sources */, AB63EADD2699AC9100090535 /* AppEnvMO+CoreDataClass.swift in Sources */, AB7BF2C027A9669A001865A3 /* TagNamespace.swift in Sources */, ABCA93BE26918DE100A98BC6 /* Persistence.swift in Sources */, - AB86ABF92782EC0D00E61E6A /* EhPandaView.swift in Sources */, + AB86ABF92782EC0D00E61E6A /* AboutView.swift in Sources */, AB7BF2BA27A96562001865A3 /* Gallery.swift in Sources */, - AB0929BE2780032400F107CA /* EhSettingStore.swift in Sources */, + AB0929BE2780032400F107CA /* EhSettingReducer.swift in Sources */, ABC8356127B357C50091DCDB /* GestureHandler.swift in Sources */, AB0929D42781EDDC00F107CA /* UserDefaultsClient.swift in Sources */, AB0929D82782A83A00F107CA /* AuthorizationClient.swift in Sources */, ABF45AEF25F3313D00ECB568 /* TorrentsView.swift in Sources */, - AB706F99278A820C0025A48A /* FiltersStore.swift in Sources */, + AB706F99278A820C0025A48A /* FiltersReducer.swift in Sources */, AB3072D4276E19AA00EFF242 /* FrontpageView.swift in Sources */, AB3072D2276D734800EFF242 /* SubSection.swift in Sources */, ABBB26682797BFAA007B6149 /* ActivityView.swift in Sources */, @@ -1707,23 +1793,22 @@ AB0CFBD527C24B3B004BD372 /* MarkdownUtil.swift in Sources */, ABF45AF625F3313D00ECB568 /* AppearanceSettingView.swift in Sources */, AB7BF2CE27AA3E58001865A3 /* AppUtil.swift in Sources */, - AB86AC1A2785C2B300E61E6A /* HomeStore.swift in Sources */, - AB7BF2D427AA3F12001865A3 /* CookiesUtil.swift in Sources */, + AB86AC1A2785C2B300E61E6A /* HomeReducer.swift in Sources */, + AB7BF2D427AA3F12001865A3 /* CookieUtil.swift in Sources */, AB7BF30A27ABDFF1001865A3 /* CoreDataMigrationStep.swift in Sources */, AB69CB8226B3DAF400699359 /* ControlPanel.swift in Sources */, - AB7BF2D227AA3EDC001865A3 /* HapticUtil.swift in Sources */, - ABD49D5A277C5356003D1A07 /* FavoritesStore.swift in Sources */, + AB7BF2D227AA3EDC001865A3 /* HapticsUtil.swift in Sources */, + ABD49D5A277C5356003D1A07 /* FavoritesReducer.swift in Sources */, AB1EF25427AFA19200F507D6 /* Heap.swift in Sources */, AB7BF2C227A96760001865A3 /* GalleryDetail.swift in Sources */, ABE1867826A1733000689FDC /* LaboratorySettingView.swift in Sources */, ABF45AEB25F3313D00ECB568 /* GalleryDetailCell.swift in Sources */, AB69CB8026B3DABC00699359 /* AdvancedList.swift in Sources */, ABC3C7892593699B00E0C11B /* Defaults.swift in Sources */, - ABD49D6A277EEF73003D1A07 /* SettingStore.swift in Sources */, AB8C821926BF801700E8C5E6 /* EhSetting.swift in Sources */, - AB86AC1327856F2700E61E6A /* AppLockStore.swift in Sources */, - AB58A5AC2776B2BC00C0D285 /* AppDelegateStore.swift in Sources */, - ABBB263E2793C648007B6149 /* PreviewsStore.swift in Sources */, + AB86AC1327856F2700E61E6A /* AppLockReducer.swift in Sources */, + AB58A5AC2776B2BC00C0D285 /* AppDelegateReducer.swift in Sources */, + ABBB263E2793C648007B6149 /* PreviewsReducer.swift in Sources */, ABBC332A26BE7C940084A331 /* SettingTextField.swift in Sources */, AB358317269D826B009466A5 /* DFStreamHandler.swift in Sources */, AB7BF2CA27A969F4001865A3 /* GalleryState.swift in Sources */, @@ -1734,7 +1819,7 @@ AB0CFBCB27C0B07F004BD372 /* TagSuggestion.swift in Sources */, AB7BF30727ABDFF1001865A3 /* CoreDataMigrator.swift in Sources */, ABC3C78F2593699B00E0C11B /* ViewModifiers.swift in Sources */, - AB86ABF52782DAB300E61E6A /* LogsStore.swift in Sources */, + AB86ABF52782DAB300E61E6A /* LogsReducer.swift in Sources */, AB7BF2AB27A642FB001865A3 /* BrowsingCountry.swift in Sources */, ABD49D60277C7722003D1A07 /* TabBarView.swift in Sources */, AB26F59027ABF21000AB3468 /* Model5toModel6.xcmappingmodel in Sources */, @@ -1745,39 +1830,38 @@ ABCD2F0E25976B95008E5A20 /* Parser.swift in Sources */, ABF45AF725F3313D00ECB568 /* SettingView.swift in Sources */, AB1FA8FC27C5E0E50063EF55 /* TagDetail.swift in Sources */, - AB706F862789AD490025A48A /* ToplistsStore.swift in Sources */, + AB706F862789AD490025A48A /* ToplistsReducer.swift in Sources */, AB7BF2CC27A96A3C001865A3 /* GalleryTorrent.swift in Sources */, ABF45AEA25F3313D00ECB568 /* Placeholder.swift in Sources */, ABD4032826B7967F00001B8C /* CategoryView.swift in Sources */, ABC681F326898D46007BBD69 /* Model.xcdatamodeld in Sources */, - ABBB266627977C2A007B6149 /* ArchivesStore.swift in Sources */, - ABBB2640279417EC007B6149 /* CommentsStore.swift in Sources */, - AB0929C027805A8200F107CA /* LoginStore.swift in Sources */, + ABBB266627977C2A007B6149 /* ArchivesReducer.swift in Sources */, + ABBB2640279417EC007B6149 /* CommentsReducer.swift in Sources */, + AB0929C027805A8200F107CA /* LoginReducer.swift in Sources */, ABBB2631278E6EF3007B6149 /* SearchView.swift in Sources */, - AB706F92278A6E8C0025A48A /* WatchedStore.swift in Sources */, + AB706F92278A6E8C0025A48A /* WatchedReducer.swift in Sources */, AB706F80278981370025A48A /* AlertKit_Extension.swift in Sources */, - ABBB2633278E6F3B007B6149 /* SearchStore.swift in Sources */, ABA9A6C228EC7BD000EE28DE /* Strings.swift in Sources */, - AB58A5B22776B99000C0D285 /* AppStore.swift in Sources */, + AB58A5B22776B99000C0D285 /* AppReducer.swift in Sources */, AB24C566276758E30085C33A /* GalleryCardCell.swift in Sources */, - ABBB2679279D454C007B6149 /* GalleryInfosStore.swift in Sources */, + ABBB2679279D454C007B6149 /* GalleryInfosReducer.swift in Sources */, AB7BF2B727A9652F001865A3 /* Greeting.swift in Sources */, - ABC8355F27B118370091DCDB /* DetailSearchStore.swift in Sources */, + ABC8355F27B118370091DCDB /* DetailSearchReducer.swift in Sources */, AB4FD2C1268AB83300A95968 /* GalleryDetailMO+CoreDataProperties.swift in Sources */, AB26F59427ACC6CD00AB3468 /* TagTranslator.swift in Sources */, AB0929CE2781AADA00F107CA /* DatabaseClient.swift in Sources */, AB6DE897268822390087C579 /* LogsView.swift in Sources */, AB7BF31D27ABE028001865A3 /* FileManager+ApplicationSupport.swift in Sources */, - AB706F7B278937500025A48A /* FrontpageStore.swift in Sources */, + AB706F7B278937500025A48A /* FrontpageReducer.swift in Sources */, AB86ABF72782DDE600E61E6A /* FileClient.swift in Sources */, AB7B29F226AC471E00EE1F14 /* Model5toModel6MigrationPolicy.swift in Sources */, - AB706F7927890A6C0025A48A /* AppRouteStore.swift in Sources */, + AB706F7927890A6C0025A48A /* AppRouteReducer.swift in Sources */, AB706F88278A4C8A0025A48A /* PopularView.swift in Sources */, AB706FA1278BCEC60025A48A /* DetailView.swift in Sources */, ABE9401526FF158D0085E158 /* QuickSearchView.swift in Sources */, AB706F9B278AC5A30025A48A /* SearchRootView.swift in Sources */, - AB706F8A278A4CC50025A48A /* PopularStore.swift in Sources */, - ABD49D64277C7AD5003D1A07 /* TabBarStore.swift in Sources */, + AB706F8A278A4CC50025A48A /* PopularReducer.swift in Sources */, + ABD49D64277C7AD5003D1A07 /* TabBarReducer.swift in Sources */, ABF45AF025F3313D00ECB568 /* CommentsView.swift in Sources */, ABC8356527B36E550091DCDB /* AutoPlayHandler.swift in Sources */, ABBB2671279AFA61007B6149 /* EnvironmentKeys.swift in Sources */, @@ -1786,22 +1870,22 @@ ABBB263A2792588F007B6149 /* TTProgressHUD_Extension.swift in Sources */, AB7BF2D627AA3F4C001865A3 /* FileUtil.swift in Sources */, AB0ABCB726C541A400AD970F /* WaveForm.swift in Sources */, - AB0929D62782A65F00F107CA /* GeneralSettingStore.swift in Sources */, - AB706FA3278BCF2F0025A48A /* DetailStore.swift in Sources */, + AB0929D62782A65F00F107CA /* GeneralSettingReducer.swift in Sources */, + AB706FA3278BCF2F0025A48A /* DetailReducer.swift in Sources */, ABBCCC9026C95F6E007D8A36 /* GalleryInfosView.swift in Sources */, AB7BF2A927A63C89001865A3 /* Language.swift in Sources */, - AB86AC0A2782FAFA00E61E6A /* AppearanceSettingStore.swift in Sources */, + AB86AC0A2782FAFA00E61E6A /* AppearanceSettingReducer.swift in Sources */, AB7BF2C427A9683F001865A3 /* GalleryArchive.swift in Sources */, ABF45AF525F3313D00ECB568 /* ReadingSettingView.swift in Sources */, AB0CFBD727C3B2D0004BD372 /* TagDetailView.swift in Sources */, AB38A0CB25CA993D00764D64 /* ColorCodable.swift in Sources */, ABC732C127B8962000D47DA9 /* LiveTextHandler.swift in Sources */, - ABBB2675279B933D007B6149 /* ReadingStore.swift in Sources */, + ABBB2675279B933D007B6149 /* ReadingReducer.swift in Sources */, ABF45AF425F3313D00ECB568 /* WebView.swift in Sources */, AB7B29F626AC741600EE1F14 /* GenericList.swift in Sources */, AB0CFBC927C07F95004BD372 /* TagSuggestionView.swift in Sources */, AB706F82278986120025A48A /* ToolbarItems.swift in Sources */, - ABBB266E27998479007B6149 /* QuickSearchStore.swift in Sources */, + ABBB266E27998479007B6149 /* QuickSearchReducer.swift in Sources */, AB0ABCB526C5406400AD970F /* LoginView.swift in Sources */, AB24C55C2767565A0085C33A /* HomeView.swift in Sources */, ABF45AF225F3313D00ECB568 /* GeneralSettingView.swift in Sources */, @@ -1825,7 +1909,7 @@ AB358315269D821D009466A5 /* DFExtensions.swift in Sources */, ABC3C7872593699B00E0C11B /* EhPandaApp.swift in Sources */, AB0929C82781938A00F107CA /* DFClient.swift in Sources */, - AB706F9D278ACCA20025A48A /* SearchRootStore.swift in Sources */, + AB706F9D278ACCA20025A48A /* SearchRootReducer.swift in Sources */, ABF313A525B1AB6600D47A2F /* Misc.swift in Sources */, ABA732DF25A852D800B3D9AB /* Filter.swift in Sources */, AB7BF31E27ABE028001865A3 /* NSPersistentStoreCoordinator+SQLite.swift in Sources */, @@ -1833,7 +1917,7 @@ ABC732C527B9024500D47DA9 /* LiveText.swift in Sources */, ABF45AE825F3313D00ECB568 /* LinkedText.swift in Sources */, ABC732C727B90F0900D47DA9 /* LiveTextView.swift in Sources */, - AB0929B6277F043D00F107CA /* AccountSettingStore.swift in Sources */, + AB0929B6277F043D00F107CA /* AccountSettingReducer.swift in Sources */, ABD49D67277EAC90003D1A07 /* URLUtil.swift in Sources */, ABBB2638278FBD2F007B6149 /* SwiftUINavigation_Extension.swift in Sources */, AB10117E26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift in Sources */, @@ -1841,7 +1925,7 @@ ABF45AE525F3313D00ECB568 /* PostCommentView.swift in Sources */, AB358313269D7E89009466A5 /* DFRequest.swift in Sources */, AB706F90278A5F680025A48A /* AppDelegateClient.swift in Sources */, - ABBB266A2797C61F007B6149 /* TorrentsStore.swift in Sources */, + ABBB266A2797C61F007B6149 /* TorrentsReducer.swift in Sources */, ABF75F3F25A19CD200544D29 /* User.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1854,14 +1938,11 @@ AB0CFB8227BBBFCE004BD372 /* EhSettingParserTests.swift in Sources */, AB31CD4327B676C300F40E0A /* GalleryMPVKeysParserTests.swift in Sources */, AB31CD3027B666E200F40E0A /* TestError.swift in Sources */, - ABAB5B9727EF03F600198597 /* AppearanceSettingStoreTests.swift in Sources */, ABD9771027B65E3400983DE7 /* GalleryDetailParserTests.swift in Sources */, AB31CD3227B6671400F40E0A /* BanIntervalParserTests.swift in Sources */, ABD9771327B6612400983DE7 /* GreetingParserTests.swift in Sources */, AB31CD3727B6695800F40E0A /* HTMLFilename.swift in Sources */, - ABA12F3227D49CEB0021922D /* AccountSettingStoreTests.swift in Sources */, AB3E9E7426D210B1008FE518 /* TestHelper.swift in Sources */, - AB6D106A27EBF890003A2245 /* GeneralSettingStoreTests.swift in Sources */, AB31CD3B27B66E0300F40E0A /* ListParserTestType.swift in Sources */, ABD9770E27B65A7300983DE7 /* ListParserTests.swift in Sources */, ABAB5B9527EF023300198597 /* Extensions.swift in Sources */, @@ -2307,20 +2388,12 @@ kind = branch; }; }; - AB2EB9A92802525700011A8A /* XCRemoteSwiftPackageReference "SwiftyBeaver" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/EhPanda-Team/SwiftyBeaver.git"; - requirement = { - branch = custom; - kind = branch; - }; - }; AB60D0E7274C7ECE00F899AB /* XCRemoteSwiftPackageReference "WaterfallGrid" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/paololeonardi/WaterfallGrid.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.0.1; + minimumVersion = 1.0.0; }; }; AB65059E26B0027800F91E9D /* XCRemoteSwiftPackageReference "SwiftUIPager" */ = { @@ -2336,7 +2409,7 @@ repositoryURL = "https://github.com/pointfreeco/swift-composable-architecture.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.32.0; + minimumVersion = 0.0.0; }; }; ABAC82FC26BC4866009F5026 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */ = { @@ -2352,7 +2425,7 @@ repositoryURL = "https://github.com/pointfreeco/swiftui-navigation.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.1.0; + minimumVersion = 0.0.0; }; }; ABC4A0772751B40E00968A4F /* XCRemoteSwiftPackageReference "Kingfisher" */ = { @@ -2360,15 +2433,15 @@ repositoryURL = "https://github.com/onevcat/Kingfisher.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 7.1.2; + minimumVersion = 7.0.0; }; }; ABD49D5B277C6C9D003D1A07 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols.git"; requirement = { - branch = stable; - kind = branch; + kind = upToNextMajorVersion; + minimumVersion = 4.0.0; }; }; ABD7005726B1C31500DC59C9 /* XCRemoteSwiftPackageReference "Kanna" */ = { @@ -2379,12 +2452,12 @@ minimumVersion = 5.0.0; }; }; - ABD970B527A2A6BD001693B0 /* XCRemoteSwiftPackageReference "R.swift.Library" */ = { + EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/mac-cain13/R.swift.Library.git"; + repositoryURL = "https://github.com/SwiftyBeaver/SwiftyBeaver.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 5.4.0; + minimumVersion = 2.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -2425,11 +2498,6 @@ package = AB2EB9A32802521700011A8A /* XCRemoteSwiftPackageReference "DeprecatedAPI" */; productName = DeprecatedAPI; }; - AB2EB9AA2802525700011A8A /* SwiftyBeaver */ = { - isa = XCSwiftPackageProductDependency; - package = AB2EB9A92802525700011A8A /* XCRemoteSwiftPackageReference "SwiftyBeaver" */; - productName = SwiftyBeaver; - }; AB60D0E8274C7ECE00F899AB /* WaterfallGrid */ = { isa = XCSwiftPackageProductDependency; package = AB60D0E7274C7ECE00F899AB /* XCRemoteSwiftPackageReference "WaterfallGrid" */; @@ -2470,10 +2538,10 @@ package = ABD7005726B1C31500DC59C9 /* XCRemoteSwiftPackageReference "Kanna" */; productName = Kanna; }; - ABD970B627A2A6BD001693B0 /* Rswift */ = { + EAE63E2029E2A6330048C601 /* SwiftyBeaver */ = { isa = XCSwiftPackageProductDependency; - package = ABD970B527A2A6BD001693B0 /* XCRemoteSwiftPackageReference "R.swift.Library" */; - productName = Rswift; + package = EAE63E1F29E2A6330048C601 /* XCRemoteSwiftPackageReference "SwiftyBeaver" */; + productName = SwiftyBeaver; }; /* End XCSwiftPackageProductDependency section */ diff --git a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9e0508b0..1ccc3979 100644 --- a/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/EhPanda.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "4cf088c29a20f52be0f2ca54992b492c54e0076b", - "version" : "0.5.3" + "revision" : "0625932976b3ae23949f6b816d13bd97f3b40b7c", + "version" : "0.10.0" } }, { @@ -59,35 +59,35 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { - "revision" : "32e4acdf6971f58f5ad552389cf2d7d016334eaf", - "version" : "7.2.0" + "revision" : "e8625b80c413457b13ea9be75d07f6e9f5830c19", + "version" : "7.7.0" } }, { - "identity" : "r.swift.library", + "identity" : "sfsafesymbols", "kind" : "remoteSourceControl", - "location" : "https://github.com/mac-cain13/R.swift.Library.git", + "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols.git", "state" : { - "revision" : "8998cfe77f4fce79ee6dfab0c88a7d551659d8fb", - "version" : "5.4.0" + "revision" : "7cca2d60925876b5953a2cf7341cd80fbeac983c", + "version" : "4.1.1" } }, { - "identity" : "sfsafesymbols", + "identity" : "swift-case-paths", "kind" : "remoteSourceControl", - "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols.git", + "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "branch" : "stable", - "revision" : "de65e5545623c32c86da259cb0987c381bc6a9e2" + "revision" : "fc45e7b2cfece9dd80b5a45e6469ffe67fe67984", + "version" : "0.14.1" } }, { - "identity" : "swift-case-paths", + "identity" : "swift-clocks", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-case-paths", + "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "241301b67d8551c26d8f09bd2c0e52cc49f18007", - "version" : "0.8.0" + "revision" : "f9acfa1a45f4483fe0f2c434a74e6f68f865d12d", + "version" : "0.3.0" } }, { @@ -95,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "48254824bb4248676bf7ce56014ff57b142b77eb", - "version" : "1.0.2" + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" } }, { @@ -104,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture.git", "state" : { - "revision" : "2828dc44f6e3f81d84bcaba72c1ab1c0121d66f6", - "version" : "0.34.0" + "revision" : "b6559103c7867821b3848afe29afc1a386ae83f1", + "version" : "0.53.2" } }, { @@ -113,8 +113,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "51698ece74ecf31959d3fa81733f0a5363ef1b4e", - "version" : "0.3.0" + "revision" : "505aa98716275fbd045d8f934fee3337c82ffbd3", + "version" : "0.10.3" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "25c9b6789b4b7ada649a3808e6d8de1489082a33", + "version" : "0.5.0" } }, { @@ -122,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "680bf440178a78a627b1c2c64c0855f6523ad5b9", - "version" : "0.3.2" + "revision" : "f52eee28bdc6065aa2f8424067e6f04c74bda6e6", + "version" : "0.7.1" } }, { @@ -140,8 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swiftui-navigation.git", "state" : { - "revision" : "2694c03284a368168b3e0b8d7ab52626802d2246", - "version" : "0.1.0" + "revision" : "47dd574b900ba5ba679f56ea00d4d282fc7305a6", + "version" : "0.7.1" } }, { @@ -149,17 +158,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/fermoya/SwiftUIPager.git", "state" : { - "revision" : "6e4170ef7cafd04b3d8ab8dc5b0469036b2bcd6a", - "version" : "2.4.0" + "revision" : "4ddc04c801aac143090bb14cf26603a3bf9c74cb", + "version" : "2.5.0" } }, { "identity" : "swiftybeaver", "kind" : "remoteSourceControl", - "location" : "https://github.com/EhPanda-Team/SwiftyBeaver.git", + "location" : "https://github.com/SwiftyBeaver/SwiftyBeaver.git", "state" : { - "branch" : "custom", - "revision" : "2e8a65567ca877a7fdf6a63ab53b2c49a927af87" + "revision" : "1080914828ef1c9ca9cd2bad50667b3d847dabff", + "version" : "2.0.0" } }, { @@ -203,8 +212,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "50a70a9d3583fe228ce672e8923010c8df2deddd", - "version" : "0.2.1" + "revision" : "4af50b38daf0037cfbab15514a241224c3f62f98", + "version" : "0.8.5" } } ], diff --git a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme index 621870f0..e791012e 100644 --- a/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme +++ b/EhPanda.xcodeproj/xcshareddata/xcschemes/EhPanda.xcscheme @@ -1,6 +1,6 @@ Effect - let setOrientationMask: (UIInterfaceOrientationMask) -> Effect + let setOrientation: (UIInterfaceOrientationMask) -> EffectTask + let setOrientationMask: (UIInterfaceOrientationMask) -> EffectTask } extension AppDelegateClient { static let live: Self = .init( - setOrientation: { orientation in + setOrientation: { mask in .fireAndForget { - UIDevice.current.setValue(orientation.rawValue, forKey: "orientation") + DeviceUtil.keyWindow?.windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) } }, setOrientationMask: { mask in @@ -27,13 +27,40 @@ extension AppDelegateClient { } ) - func setPortraitOrientation() -> Effect { + func setPortraitOrientation() -> EffectTask { setOrientation(.portrait) } - func setAllOrientationMask() -> Effect { + func setAllOrientationMask() -> EffectTask { setOrientationMask([.all]) } - func setPortraitOrientationMask() -> Effect { + func setPortraitOrientationMask() -> EffectTask { setOrientationMask([.portrait, .portraitUpsideDown]) } } + +// MARK: API +enum AppDelegateClientKey: DependencyKey { + static let liveValue = AppDelegateClient.live + static let previewValue = AppDelegateClient.noop + static let testValue = AppDelegateClient.unimplemented +} + +extension DependencyValues { + var appDelegateClient: AppDelegateClient { + get { self[AppDelegateClientKey.self] } + set { self[AppDelegateClientKey.self] = newValue } + } +} + +// MARK: Test +extension AppDelegateClient { + static let noop: Self = .init( + setOrientation: { _ in .none }, + setOrientationMask: { _ in .none } + ) + + static let unimplemented: Self = .init( + setOrientation: XCTestDynamicOverlay.unimplemented("\(Self.self).setOrientation"), + setOrientationMask: XCTestDynamicOverlay.unimplemented("\(Self.self).setOrientationMask") + ) +} diff --git a/EhPanda/App/Tools/Clients/AuthorizationClient.swift b/EhPanda/App/Tools/Clients/AuthorizationClient.swift index 3d8cfa1f..442811fe 100644 --- a/EhPanda/App/Tools/Clients/AuthorizationClient.swift +++ b/EhPanda/App/Tools/Clients/AuthorizationClient.swift @@ -11,7 +11,7 @@ import ComposableArchitecture struct AuthorizationClient { let passcodeNotSet: () -> Bool - let localAuthroize: (String) -> Effect + let localAuthroize: (String) -> EffectTask } extension AuthorizationClient { @@ -40,23 +40,29 @@ extension AuthorizationClient { ) } -// MARK: Test -#if DEBUG -import XCTestDynamicOverlay +// MARK: API +enum AuthorizationClientKey: DependencyKey { + static let liveValue = AuthorizationClient.live + static let previewValue = AuthorizationClient.noop + static let testValue = AuthorizationClient.unimplemented +} -extension AuthorizationClient { - static let failing: Self = .init( - passcodeNotSet: { - XCTFail("\(Self.self).passcodeNotSet is unimplemented") - return false - }, - localAuthroize: { .failing("\(Self.self).localAuthroize(\($0)) is unimplemented")} - ) +extension DependencyValues { + var authorizationClient: AuthorizationClient { + get { self[AuthorizationClientKey.self] } + set { self[AuthorizationClientKey.self] = newValue } + } } -#endif + +// MARK: Test extension AuthorizationClient { static let noop: Self = .init( passcodeNotSet: { false }, localAuthroize: { _ in .none } ) + + static let unimplemented: Self = .init( + passcodeNotSet: XCTestDynamicOverlay.unimplemented("\(Self.self).passcodeNotSet"), + localAuthroize: XCTestDynamicOverlay.unimplemented("\(Self.self).localAuthroize") + ) } diff --git a/EhPanda/App/Tools/Clients/ClipboardClient.swift b/EhPanda/App/Tools/Clients/ClipboardClient.swift index f71768dd..4c72c63f 100644 --- a/EhPanda/App/Tools/Clients/ClipboardClient.swift +++ b/EhPanda/App/Tools/Clients/ClipboardClient.swift @@ -12,8 +12,8 @@ import UniformTypeIdentifiers struct ClipboardClient { let url: () -> URL? let changeCount: () -> Int - let saveText: (String) -> Effect - let saveImage: (UIImage, Bool) -> Effect + let saveText: (String) -> EffectTask + let saveImage: (UIImage, Bool) -> EffectTask } extension ClipboardClient { @@ -49,25 +49,21 @@ extension ClipboardClient { ) } -// MARK: Test -#if DEBUG -import XCTestDynamicOverlay +// MARK: API +enum ClipboardClientKey: DependencyKey { + static let liveValue = ClipboardClient.live + static let previewValue = ClipboardClient.noop + static let testValue = ClipboardClient.unimplemented +} -extension ClipboardClient { - static let failing: Self = .init( - url: { - XCTFail("\(Self.self).url is unimplemented") - return nil - }, - changeCount: { - XCTFail("\(Self.self).changeCount is unimplemented") - return 0 - }, - saveText: { .failing("\(Self.self).saveText(\($0)) is unimplemented") }, - saveImage: { .failing("\(Self.self).saveImage(\($0), \($1)) is unimplemented") } - ) +extension DependencyValues { + var clipboardClient: ClipboardClient { + get { self[ClipboardClientKey.self] } + set { self[ClipboardClientKey.self] = newValue } + } } -#endif + +// MARK: Test extension ClipboardClient { static let noop: Self = .init( url: { nil }, @@ -75,4 +71,11 @@ extension ClipboardClient { saveText: { _ in .none }, saveImage: { _, _ in .none } ) + + static let unimplemented: Self = .init( + url: XCTestDynamicOverlay.unimplemented("\(Self.self).url"), + changeCount: XCTestDynamicOverlay.unimplemented("\(Self.self).changeCount"), + saveText: XCTestDynamicOverlay.unimplemented("\(Self.self).saveText"), + saveImage: XCTestDynamicOverlay.unimplemented("\(Self.self).saveImage") + ) } diff --git a/EhPanda/App/Tools/Clients/CookiesClient.swift b/EhPanda/App/Tools/Clients/CookieClient.swift similarity index 78% rename from EhPanda/App/Tools/Clients/CookiesClient.swift rename to EhPanda/App/Tools/Clients/CookieClient.swift index 4642395c..86143d11 100644 --- a/EhPanda/App/Tools/Clients/CookiesClient.swift +++ b/EhPanda/App/Tools/Clients/CookieClient.swift @@ -1,5 +1,5 @@ // -// CookiesClient.swift +// CookieClient.swift // EhPanda // // Created by 荒木辰造 on R 4/01/02. @@ -8,15 +8,15 @@ import Foundation import ComposableArchitecture -struct CookiesClient { - let clearAll: () -> Effect +struct CookieClient { + let clearAll: () -> EffectTask let getCookie: (URL, String) -> CookieValue private let removeCookie: (URL, String) -> Void private let checkExistence: (URL, String) -> Bool private let initializeCookie: (HTTPCookie, String) -> HTTPCookie } -extension CookiesClient { +extension CookieClient { static let live: Self = .init( clearAll: { .fireAndForget { @@ -82,7 +82,7 @@ extension CookiesClient { } // MARK: Foundation -extension CookiesClient { +extension CookieClient { private func setCookie( for url: URL, key: String, value: String, path: String = "/", expiresTime: TimeInterval = .oneYear @@ -108,7 +108,7 @@ extension CookiesClient { guard let cookie = newCookie else { return } HTTPCookieStorage.shared.setCookie(cookie) } - func setOrEditCookie(for url: URL, key: String, value: String) -> Effect { + func setOrEditCookie(for url: URL, key: String, value: String) -> EffectTask { .fireAndForget { if checkExistence(url, key) { editCookie(for: url, key: key, value: value) @@ -120,9 +120,9 @@ extension CookiesClient { } // MARK: Accessor -extension CookiesClient { +extension CookieClient { var didLogin: Bool { - CookiesUtil.didLogin + CookieUtil.didLogin } var apiuid: String { getCookie(Defaults.URL.host, Defaults.Cookie.ipbMemberId).rawValue @@ -138,18 +138,35 @@ extension CookiesClient { && !getCookie(url, Defaults.Cookie.ipbPassHash).rawValue.isEmpty && getCookie(url, Defaults.Cookie.igneous).rawValue.isEmpty } - func removeYay() -> Effect { + func removeYay() -> EffectTask { .fireAndForget { removeCookie(Defaults.URL.exhentai, Defaults.Cookie.yay) + removeCookie(Defaults.URL.sexhentai, Defaults.Cookie.yay) } } - func ignoreOffensive() -> Effect { + func syncExCookies() -> EffectTask { + .merge( + [ + Defaults.Cookie.ipbMemberId, + Defaults.Cookie.ipbPassHash, + Defaults.Cookie.igneous + ] + .map { + setOrEditCookie( + for: Defaults.URL.sexhentai, + key: $0, + value: getCookie(Defaults.URL.exhentai, $0).rawValue + ) + } + ) + } + func ignoreOffensive() -> EffectTask { .merge( setOrEditCookie(for: Defaults.URL.ehentai, key: Defaults.Cookie.ignoreOffensive, value: "1"), setOrEditCookie(for: Defaults.URL.exhentai, key: Defaults.Cookie.ignoreOffensive, value: "1") ) } - func fulfillAnotherHostField() -> Effect { + func fulfillAnotherHostField() -> EffectTask { let ehURL = Defaults.URL.ehentai let exURL = Defaults.URL.exhentai let memberIdKey = Defaults.Cookie.ipbMemberId @@ -200,14 +217,23 @@ extension CookiesClient { } // MARK: SetCookies -extension CookiesClient { - func setCookies(state: CookiesState) -> Effect { - let effects: [Effect] = state.allCases.map { subState in - setOrEditCookie(for: state.host.url, key: subState.key, value: subState.editingText) - } +extension CookieClient { + func setCookies(state: CookiesState, trimsSpaces: Bool = true) -> EffectTask { + let effects: [EffectTask] = state.allCases + .flatMap { subState in + state.host.cookieURLs + .map { + setOrEditCookie( + for: $0, + key: subState.key, + value: trimsSpaces + ? subState.editingText .trimmingCharacters(in: .whitespaces) : subState.editingText + ) + } + } return effects.isEmpty ? .none : .merge(effects) } - func setCredentials(response: HTTPURLResponse) -> Effect { + func setCredentials(response: HTTPURLResponse) -> EffectTask { .fireAndForget { guard let setString = response.allHeaderFields["Set-Cookie"] as? String else { return } setString.components(separatedBy: ", ") @@ -226,7 +252,7 @@ extension CookiesClient { } } } - func setSkipServer(response: HTTPURLResponse) -> Effect { + func setSkipServer(response: HTTPURLResponse) -> EffectTask { .fireAndForget { guard let setString = response.allHeaderFields["Set-Cookie"] as? String else { return } setString.components(separatedBy: ", ") @@ -244,32 +270,22 @@ extension CookiesClient { } } -// MARK: Test -#if DEBUG -import XCTestDynamicOverlay +// MARK: API +enum CookieClientKey: DependencyKey { + static let liveValue = CookieClient.live + static let previewValue = CookieClient.noop + static let testValue = CookieClient.unimplemented +} -extension CookiesClient { - static let failing: Self = .init( - clearAll: { .failing("\(Self.self).clearAll is unimplemented") }, - getCookie: { - XCTFail("\(Self.self).getCookie(\($0), \($1)) is unimplemented") - return .empty - }, - removeCookie: { - XCTFail("\(Self.self).removeCookie(\($0), \($1)) is unimplemented") - }, - checkExistence: { - XCTFail("\(Self.self).checkExistence(\($0), \($1)) is unimplemented") - return false - }, - initializeCookie: { - XCTFail("\(Self.self).initializeCookie(\($0), \($1)) is unimplemented") - return .init() - } - ) +extension DependencyValues { + var cookieClient: CookieClient { + get { self[CookieClientKey.self] } + set { self[CookieClientKey.self] = newValue } + } } -#endif -extension CookiesClient { + +// MARK: Test +extension CookieClient { static let noop: Self = .init( clearAll: { .none }, getCookie: { _, _ in .empty }, @@ -277,4 +293,12 @@ extension CookiesClient { checkExistence: { _, _ in false }, initializeCookie: { _, _ in .init() } ) + + static let unimplemented: Self = .init( + clearAll: XCTestDynamicOverlay.unimplemented("\(Self.self).clearAll"), + getCookie: XCTestDynamicOverlay.unimplemented("\(Self.self).getCookie"), + removeCookie: XCTestDynamicOverlay.unimplemented("\(Self.self).removeCookie"), + checkExistence: XCTestDynamicOverlay.unimplemented("\(Self.self).checkExistence"), + initializeCookie: XCTestDynamicOverlay.unimplemented("\(Self.self).initializeCookie") + ) } diff --git a/EhPanda/App/Tools/Clients/DFClient.swift b/EhPanda/App/Tools/Clients/DFClient.swift index e745b9fd..54a900c5 100644 --- a/EhPanda/App/Tools/Clients/DFClient.swift +++ b/EhPanda/App/Tools/Clients/DFClient.swift @@ -5,11 +5,12 @@ // Created by 荒木辰造 on R 4/01/02. // +import Foundation import Kingfisher import ComposableArchitecture struct DFClient { - let setActive: (Bool) -> Effect + let setActive: (Bool) -> EffectTask } extension DFClient { @@ -29,3 +30,28 @@ extension DFClient { } ) } + +// MARK: API +enum DFClientKey: DependencyKey { + static let liveValue = DFClient.live + static let previewValue = DFClient.noop + static let testValue = DFClient.unimplemented +} + +extension DependencyValues { + var dfClient: DFClient { + get { self[DFClientKey.self] } + set { self[DFClientKey.self] = newValue } + } +} + +// MARK: Test +extension DFClient { + static let noop: Self = .init( + setActive: { _ in .none } + ) + + static let unimplemented: Self = .init( + setActive: XCTestDynamicOverlay.unimplemented("\(Self.self).setActive") + ) +} diff --git a/EhPanda/App/Tools/Clients/DatabaseClient.swift b/EhPanda/App/Tools/Clients/DatabaseClient.swift index 077fa973..574f0fb0 100644 --- a/EhPanda/App/Tools/Clients/DatabaseClient.swift +++ b/EhPanda/App/Tools/Clients/DatabaseClient.swift @@ -11,8 +11,8 @@ import CoreData import ComposableArchitecture struct DatabaseClient { - let prepareDatabase: () -> Effect, Never> - let dropDatabase: () -> Effect, Never> + let prepareDatabase: () -> EffectTask + let dropDatabase: () -> EffectTask private let saveContext: () -> Void private let materializedObjects: (NSManagedObjectContext, NSPredicate) -> [NSManagedObject] } @@ -21,19 +21,35 @@ extension DatabaseClient { static let live: Self = .init( prepareDatabase: { Future { promise in - PersistenceController.shared.prepare(completion: promise) + PersistenceController.shared.prepare { + switch $0 { + case .success: + promise(.success(nil)) + + case .failure(let appError): + promise(.success(appError)) + } + } } .eraseToAnyPublisher() .receive(on: DispatchQueue.main) - .catchToEffect() + .eraseToEffect() }, dropDatabase: { Future { promise in - PersistenceController.shared.rebuild(completion: promise) + PersistenceController.shared.rebuild { + switch $0 { + case .success: + promise(.success(nil)) + + case .failure(let appError): + promise(.success(appError)) + } + } } .eraseToAnyPublisher() .receive(on: DispatchQueue.main) - .catchToEffect() + .eraseToEffect() }, saveContext: { let context = PersistenceController.shared.container.viewContext @@ -200,7 +216,7 @@ extension DatabaseClient { } return entity } - func fetchAppEnv() -> Effect { + func fetchAppEnv() -> EffectTask { Future { promise in DispatchQueue.main.async { promise(.success(fetchOrCreate(entityType: AppEnvMO.self).toEntity())) @@ -213,7 +229,7 @@ extension DatabaseClient { func fetchAppEnvSynchronously() -> AppEnv { fetchOrCreate(entityType: AppEnvMO.self).toEntity() } - func fetchGalleryState(gid: String) -> Effect { + func fetchGalleryState(gid: String) -> EffectTask { guard gid.isValidGID else { return .none } return Future { promise in DispatchQueue.main.async { @@ -226,7 +242,7 @@ extension DatabaseClient { .receive(on: DispatchQueue.main) .eraseToEffect() } - func fetchHistoryGalleries(fetchLimit: Int = 0) -> Effect<[Gallery], Never> { + func fetchHistoryGalleries(fetchLimit: Int = 0) -> EffectTask<[Gallery]> { Future { promise in DispatchQueue.main.async { let predicate = NSPredicate(format: "lastOpenDate != nil") @@ -258,13 +274,13 @@ extension DatabaseClient { return fetchAppEnvSynchronously().watchedFilter } } - func fetchHistoryKeywords() -> Effect<[String], Never> { + func fetchHistoryKeywords() -> EffectTask<[String]> { fetchAppEnv().map(\.historyKeywords) } - func fetchQuickSearchWords() -> Effect<[QuickSearchWord], Never> { + func fetchQuickSearchWords() -> EffectTask<[QuickSearchWord]> { fetchAppEnv().map(\.quickSearchWords) } - func fetchGalleryPreviewURLs(gid: String) -> Effect<[Int: URL], Never> { + func fetchGalleryPreviewURLs(gid: String) -> EffectTask<[Int: URL]> { guard gid.isValidGID else { return .none } return fetchGalleryState(gid: gid).map(\.previewURLs) } @@ -272,7 +288,7 @@ extension DatabaseClient { // MARK: UpdateGallery extension DatabaseClient { - func updateGallery(gid: String, key: String, value: Any?) -> Effect { + func updateGallery(gid: String, key: String, value: Any?) -> EffectTask { guard gid.isValidGID else { return .none } return .fireAndForget { DispatchQueue.main.async { @@ -283,11 +299,11 @@ extension DatabaseClient { } } } - func updateLastOpenDate(gid: String, date: Date = .now) -> Effect { + func updateLastOpenDate(gid: String, date: Date = .now) -> EffectTask { guard gid.isValidGID else { return .none } return updateGallery(gid: gid, key: "lastOpenDate", value: date) } - func clearHistoryGalleries() -> Effect { + func clearHistoryGalleries() -> EffectTask { .fireAndForget { DispatchQueue.main.async { let predicate = NSPredicate(format: "lastOpenDate != nil") @@ -299,7 +315,7 @@ extension DatabaseClient { } } } - func cacheGalleries(_ galleries: [Gallery]) -> Effect { + func cacheGalleries(_ galleries: [Gallery]) -> EffectTask { .fireAndForget { DispatchQueue.main.async { for gallery in galleries.filter({ $0.id.isValidGID }) { @@ -332,7 +348,7 @@ extension DatabaseClient { // MARK: UpdateGalleryDetail extension DatabaseClient { - func cacheGalleryDetail(_ detail: GalleryDetail) -> Effect { + func cacheGalleryDetail(_ detail: GalleryDetail) -> EffectTask { guard detail.gid.isValidGID else { return .none } return .fireAndForget { DispatchQueue.main.async { @@ -370,7 +386,7 @@ extension DatabaseClient { // MARK: UpdateGalleryState extension DatabaseClient { - func updateGalleryState(gid: String, commitChanges: @escaping (GalleryStateMO) -> Void) -> Effect { + func updateGalleryState(gid: String, commitChanges: @escaping (GalleryStateMO) -> Void) -> EffectTask { guard gid.isValidGID else { return .none } return .fireAndForget { DispatchQueue.main.async { @@ -381,30 +397,30 @@ extension DatabaseClient { } } } - func updateGalleryState(gid: String, key: String, value: Any?) -> Effect { + func updateGalleryState(gid: String, key: String, value: Any?) -> EffectTask { guard gid.isValidGID else { return .none } return updateGalleryState(gid: gid) { stateMO in stateMO.setValue(value, forKeyPath: key) } } - func updateGalleryTags(gid: String, tags: [GalleryTag]) -> Effect { + func updateGalleryTags(gid: String, tags: [GalleryTag]) -> EffectTask { guard gid.isValidGID else { return .none } return updateGalleryState(gid: gid, key: "tags", value: tags.toData()) } - func updatePreviewConfig(gid: String, config: PreviewConfig) -> Effect { + func updatePreviewConfig(gid: String, config: PreviewConfig) -> EffectTask { guard gid.isValidGID else { return .none } return updateGalleryState(gid: gid, key: "previewConfig", value: config.toData()) } - func updateReadingProgress(gid: String, progress: Int) -> Effect { + func updateReadingProgress(gid: String, progress: Int) -> EffectTask { guard gid.isValidGID else { return .none } return updateGalleryState(gid: gid, key: "readingProgress", value: Int64(progress)) } - func updateComments(gid: String, comments: [GalleryComment]) -> Effect { + func updateComments(gid: String, comments: [GalleryComment]) -> EffectTask { guard gid.isValidGID else { return .none } return updateGalleryState(gid: gid, key: "comments", value: comments.toData()) } - func removeImageURLs(gid: String) -> Effect { + func removeImageURLs(gid: String) -> EffectTask { guard gid.isValidGID else { return .none } return updateGalleryState(gid: gid) { galleryStateMO in galleryStateMO.imageURLs = nil @@ -413,7 +429,7 @@ extension DatabaseClient { galleryStateMO.originalImageURLs = nil } } - func removeImageURLs() -> Effect { + func removeImageURLs() -> EffectTask { .fireAndForget { DispatchQueue.main.async { batchUpdate(entityType: GalleryStateMO.self) { galleryStateMOs in @@ -427,14 +443,14 @@ extension DatabaseClient { } } } - func removeExpiredImageURLs() -> Effect { + func removeExpiredImageURLs() -> EffectTask { fetchHistoryGalleries() .map { $0.filter { Date().timeIntervalSince($0.lastOpenDate ?? .distantPast) > .oneWeek } } .map { $0.map { removeImageURLs(gid: $0.id) } } - .map(Effect.merge) + .map(EffectTask.merge) .fireAndForget() } - func updateThumbnailURLs(gid: String, thumbnailURLs: [Int: URL]) -> Effect { + func updateThumbnailURLs(gid: String, thumbnailURLs: [Int: URL]) -> EffectTask { guard gid.isValidGID else { return .none } return updateGalleryState(gid: gid) { galleryStateMO in update(gid: gid, storedData: &galleryStateMO.thumbnailURLs, new: thumbnailURLs) @@ -442,14 +458,14 @@ extension DatabaseClient { } func updateImageURLs( gid: String, imageURLs: [Int: URL], originalImageURLs: [Int: URL] - ) -> Effect { + ) -> EffectTask { guard gid.isValidGID else { return .none } return updateGalleryState(gid: gid) { galleryStateMO in update(gid: gid, storedData: &galleryStateMO.imageURLs, new: imageURLs) update(gid: gid, storedData: &galleryStateMO.originalImageURLs, new: originalImageURLs) } } - func updatePreviewURLs(gid: String, previewURLs: [Int: URL]) -> Effect { + func updatePreviewURLs(gid: String, previewURLs: [Int: URL]) -> EffectTask { guard gid.isValidGID else { return .none } return updateGalleryState(gid: gid) { galleryStateMO in update(gid: gid, storedData: &galleryStateMO.previewURLs, new: previewURLs) @@ -473,7 +489,7 @@ extension DatabaseClient { // MARK: UpdateAppEnv extension DatabaseClient { - func updateAppEnv(key: String, value: Any?) -> Effect { + func updateAppEnv(key: String, value: Any?) -> EffectTask { .fireAndForget { DispatchQueue.main.async { update( @@ -483,10 +499,10 @@ extension DatabaseClient { } } } - func updateSetting(_ setting: Setting) -> Effect { + func updateSetting(_ setting: Setting) -> EffectTask { updateAppEnv(key: "setting", value: setting.toData()) } - func updateFilter(_ filter: Filter, range: FilterRange) -> Effect { + func updateFilter(_ filter: Filter, range: FilterRange) -> EffectTask { let key: String switch range { case .search: @@ -498,21 +514,21 @@ extension DatabaseClient { } return updateAppEnv(key: key, value: filter.toData()) } - func updateTagTranslator(_ tagTranslator: TagTranslator) -> Effect { + func updateTagTranslator(_ tagTranslator: TagTranslator) -> EffectTask { updateAppEnv(key: "tagTranslator", value: tagTranslator.toData()) } - func updateUser(_ user: User) -> Effect { + func updateUser(_ user: User) -> EffectTask { updateAppEnv(key: "user", value: user.toData()) } - func updateHistoryKeywords(_ keywords: [String]) -> Effect { + func updateHistoryKeywords(_ keywords: [String]) -> EffectTask { updateAppEnv(key: "historyKeywords", value: keywords.toData()) } - func updateQuickSearchWords(_ words: [QuickSearchWord]) -> Effect { + func updateQuickSearchWords(_ words: [QuickSearchWord]) -> EffectTask { updateAppEnv(key: "quickSearchWords", value: words.toData()) } // Update User - func updateUserProperty(_ commitChanges: @escaping (inout User) -> Void) -> Effect { + func updateUserProperty(_ commitChanges: @escaping (inout User) -> Void) -> EffectTask { fetchAppEnv().map(\.user) .map { (user: User) -> User in var user = user @@ -522,12 +538,12 @@ extension DatabaseClient { .flatMap(updateUser) .eraseToEffect() } - func updateGreeting(_ greeting: Greeting) -> Effect { + func updateGreeting(_ greeting: Greeting) -> EffectTask { updateUserProperty { user in user.greeting = greeting } } - func updateGalleryFunds(galleryPoints: String, credits: String) -> Effect { + func updateGalleryFunds(galleryPoints: String, credits: String) -> EffectTask { updateUserProperty { user in user.credits = credits user.galleryPoints = galleryPoints @@ -535,22 +551,21 @@ extension DatabaseClient { } } -// MARK: Test -#if DEBUG -import XCTestDynamicOverlay +// MARK: API +enum DatabaseClientKey: DependencyKey { + static let liveValue = DatabaseClient.live + static let previewValue = DatabaseClient.noop + static let testValue = DatabaseClient.unimplemented +} -extension DatabaseClient { - static let failing: Self = .init( - prepareDatabase: { .failing("\(Self.self).prepareDatabase is unimplemented") }, - dropDatabase: { .failing("\(Self.self).dropDatabase is unimplemented") }, - saveContext: { XCTFail("\(Self.self).saveContext is unimplemented") }, - materializedObjects: { - XCTFail("\(Self.self).materializedObjects(\($0), \($1)) is unimplemented") - return .init() - } - ) +extension DependencyValues { + var databaseClient: DatabaseClient { + get { self[DatabaseClientKey.self] } + set { self[DatabaseClientKey.self] = newValue } + } } -#endif + +// MARK: Test extension DatabaseClient { static let noop: Self = .init( prepareDatabase: { .none }, @@ -558,4 +573,11 @@ extension DatabaseClient { saveContext: {}, materializedObjects: { _, _ in .init() } ) + + static let unimplemented: Self = .init( + prepareDatabase: XCTestDynamicOverlay.unimplemented("\(Self.self).prepareDatabase"), + dropDatabase: XCTestDynamicOverlay.unimplemented("\(Self.self).dropDatabase"), + saveContext: XCTestDynamicOverlay.unimplemented("\(Self.self).saveContext"), + materializedObjects: XCTestDynamicOverlay.unimplemented("\(Self.self).materializedObjects") + ) } diff --git a/EhPanda/App/Tools/Clients/DeviceClient.swift b/EhPanda/App/Tools/Clients/DeviceClient.swift index 710db876..6e660117 100644 --- a/EhPanda/App/Tools/Clients/DeviceClient.swift +++ b/EhPanda/App/Tools/Clients/DeviceClient.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Dependencies struct DeviceClient { let isPad: () -> Bool @@ -30,3 +31,34 @@ extension DeviceClient { } ) } + +// MARK: API +enum DeviceClientKey: DependencyKey { + static let liveValue = DeviceClient.live + static let previewValue = DeviceClient.noop + static let testValue = DeviceClient.unimplemented +} + +extension DependencyValues { + var deviceClient: DeviceClient { + get { self[DeviceClientKey.self] } + set { self[DeviceClientKey.self] = newValue } + } +} + +// MARK: Test +extension DeviceClient { + static let noop: Self = .init( + isPad: { false }, + absWindowW: { .zero }, + absWindowH: { .zero }, + touchPoint: { .zero } + ) + + static let unimplemented: Self = .init( + isPad: XCTestDynamicOverlay.unimplemented("\(Self.self).isPad"), + absWindowW: XCTestDynamicOverlay.unimplemented("\(Self.self).absWindowW"), + absWindowH: XCTestDynamicOverlay.unimplemented("\(Self.self).absWindowH"), + touchPoint: XCTestDynamicOverlay.unimplemented("\(Self.self).touchPoint") + ) +} diff --git a/EhPanda/App/Tools/Clients/FileClient.swift b/EhPanda/App/Tools/Clients/FileClient.swift index a0b85f7d..15461dd9 100644 --- a/EhPanda/App/Tools/Clients/FileClient.swift +++ b/EhPanda/App/Tools/Clients/FileClient.swift @@ -6,13 +6,14 @@ // import Combine +import Foundation import ComposableArchitecture struct FileClient { let createFile: (String, Data?) -> Bool - let fetchLogs: () -> Effect, Never> - let deleteLog: (String) -> Effect, Never> - let importTagTranslator: (URL) -> Effect, Never> + let fetchLogs: () -> EffectTask> + let deleteLog: (String) -> EffectTask> + let importTagTranslator: (URL) -> EffectTask> } extension FileClient { @@ -104,22 +105,21 @@ extension FileClient { } } -// MARK: Test -#if DEBUG -import XCTestDynamicOverlay +// MARK: API +enum FileClientKey: DependencyKey { + static let liveValue = FileClient.live + static let previewValue = FileClient.noop + static let testValue = FileClient.unimplemented +} -extension FileClient { - static let failing: Self = .init( - createFile: { - XCTFail("\(Self.self).createFile(\($0), \(String(describing: $1))) is unimplemented") - return false - }, - fetchLogs: { .failing("\(Self.self).fetchLogs is unimplemented") }, - deleteLog: { .failing("\(Self.self).deleteLog(\($0)) is unimplemented") }, - importTagTranslator: { .failing("\(Self.self).importTagTranslator(\($0)) is unimplemented") } - ) +extension DependencyValues { + var fileClient: FileClient { + get { self[FileClientKey.self] } + set { self[FileClientKey.self] = newValue } + } } -#endif + +// MARK: Test extension FileClient { static let noop: Self = .init( createFile: { _, _ in false }, @@ -127,4 +127,11 @@ extension FileClient { deleteLog: { _ in .none }, importTagTranslator: { _ in .none } ) + + static let unimplemented: Self = .init( + createFile: XCTestDynamicOverlay.unimplemented("\(Self.self).createFile"), + fetchLogs: XCTestDynamicOverlay.unimplemented("\(Self.self).fetchLogs"), + deleteLog: XCTestDynamicOverlay.unimplemented("\(Self.self).deleteLog"), + importTagTranslator: XCTestDynamicOverlay.unimplemented("\(Self.self).importTagTranslator") + ) } diff --git a/EhPanda/App/Tools/Clients/HapticClient.swift b/EhPanda/App/Tools/Clients/HapticClient.swift deleted file mode 100644 index f779eba8..00000000 --- a/EhPanda/App/Tools/Clients/HapticClient.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// HapticClient.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/02. -// - -import SwiftUI -import ComposableArchitecture - -struct HapticClient { - let generateFeedback: (UIImpactFeedbackGenerator.FeedbackStyle) -> Effect - let generateNotificationFeedback: (UINotificationFeedbackGenerator.FeedbackType) -> Effect -} - -extension HapticClient { - static let live: Self = .init( - generateFeedback: { style in - .fireAndForget { - HapticUtil.generateFeedback(style: style) - } - }, - generateNotificationFeedback: { style in - .fireAndForget { - HapticUtil.generateNotificationFeedback(style: style) - } - } - ) -} -// MARK: Test -#if DEBUG -extension HapticClient { - static let failing: Self = .init( - generateFeedback: { .failing("\(Self.self).generateFeedback(\($0)) is unimplemented") }, - generateNotificationFeedback: { .failing("\(Self.self).generateNotificationFeedback(\($0)) is unimplemented") } - ) -} -#endif -extension HapticClient { - static let noop: Self = .init( - generateFeedback: { _ in .none }, - generateNotificationFeedback: { _ in .none } - ) -} diff --git a/EhPanda/App/Tools/Clients/HapticsClient.swift b/EhPanda/App/Tools/Clients/HapticsClient.swift new file mode 100644 index 00000000..4fe2a7d5 --- /dev/null +++ b/EhPanda/App/Tools/Clients/HapticsClient.swift @@ -0,0 +1,52 @@ +// +// HapticsClient.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/02. +// + +import SwiftUI +import ComposableArchitecture + +struct HapticsClient { + let generateFeedback: (UIImpactFeedbackGenerator.FeedbackStyle) -> Void + let generateNotificationFeedback: (UINotificationFeedbackGenerator.FeedbackType) -> Void +} + +extension HapticsClient { + static let live: Self = .init( + generateFeedback: { style in + HapticsUtil.generateFeedback(style: style) + }, + generateNotificationFeedback: { style in + HapticsUtil.generateNotificationFeedback(style: style) + } + ) +} + +// MARK: API +enum HapticsClientKey: DependencyKey { + static let liveValue = HapticsClient.live + static let previewValue = HapticsClient.noop + static let testValue = HapticsClient.unimplemented +} + +extension DependencyValues { + var hapticsClient: HapticsClient { + get { self[HapticsClientKey.self] } + set { self[HapticsClientKey.self] = newValue } + } +} + +// MARK: Test +extension HapticsClient { + static let noop: Self = .init( + generateFeedback: { _ in }, + generateNotificationFeedback: { _ in } + ) + + static let unimplemented: Self = .init( + generateFeedback: XCTestDynamicOverlay.unimplemented("\(Self.self).generateFeedback"), + generateNotificationFeedback: XCTestDynamicOverlay.unimplemented("\(Self.self).generateNotificationFeedback") + ) +} diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index 84b612e5..f31a8638 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -12,10 +12,10 @@ import Kingfisher import ComposableArchitecture struct ImageClient { - let prefetchImages: ([URL]) -> Effect - let saveImageToPhotoLibrary: (UIImage, Bool) -> Effect - let downloadImage: (URL) -> Effect, Never> - let retrieveImage: (String) -> Effect, Never> + let prefetchImages: ([URL]) -> EffectTask + let saveImageToPhotoLibrary: (UIImage, Bool) -> EffectTask + let downloadImage: (URL) -> EffectTask> + let retrieveImage: (String) -> EffectTask> } extension ImageClient { @@ -78,7 +78,7 @@ extension ImageClient { } ) - func fetchImage(url: URL) -> Effect, Never> { + func fetchImage(url: URL) -> EffectTask> { if KingfisherManager.shared.cache.isCached(forKey: url.absoluteString) { return retrieveImage(url.absoluteString) } else { @@ -103,3 +103,34 @@ private final class ImageSaver: NSObject { completion(error == nil) } } + +// MARK: API +enum ImageClientKey: DependencyKey { + static let liveValue = ImageClient.live + static let previewValue = ImageClient.noop + static let testValue = ImageClient.unimplemented +} + +extension DependencyValues { + var imageClient: ImageClient { + get { self[ImageClientKey.self] } + set { self[ImageClientKey.self] = newValue } + } +} + +// MARK: Test +extension ImageClient { + static let noop: Self = .init( + prefetchImages: { _ in .none }, + saveImageToPhotoLibrary: { _, _ in .none }, + downloadImage: { _ in .none }, + retrieveImage: { _ in .none } + ) + + static let unimplemented: Self = .init( + prefetchImages: XCTestDynamicOverlay.unimplemented("\(Self.self).prefetchImages"), + saveImageToPhotoLibrary: XCTestDynamicOverlay.unimplemented("\(Self.self).saveImageToPhotoLibrary"), + downloadImage: XCTestDynamicOverlay.unimplemented("\(Self.self).downloadImage"), + retrieveImage: XCTestDynamicOverlay.unimplemented("\(Self.self).retrieveImage") + ) +} diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/EhPanda/App/Tools/Clients/LibraryClient.swift index a476725b..5f566e54 100644 --- a/EhPanda/App/Tools/Clients/LibraryClient.swift +++ b/EhPanda/App/Tools/Clients/LibraryClient.swift @@ -14,11 +14,11 @@ import UIImageColors import ComposableArchitecture struct LibraryClient { - let initializeLogger: () -> Effect - let initializeWebImage: () -> Effect - let clearWebImageDiskCache: () -> Effect - let analyzeImageColors: (UIImage) -> Effect - let calculateWebImageDiskCacheSize: () -> Effect + let initializeLogger: () -> EffectTask + let initializeWebImage: () -> EffectTask + let clearWebImageDiskCache: () -> EffectTask + let analyzeImageColors: (UIImage) -> EffectTask + let calculateWebImageDiskCacheSize: () -> EffectTask } extension LibraryClient { @@ -88,20 +88,21 @@ extension LibraryClient { ) } -// MARK: Test -#if DEBUG -import XCTestDynamicOverlay +// MARK: API +enum LibraryClientKey: DependencyKey { + static let liveValue = LibraryClient.live + static let previewValue = LibraryClient.noop + static let testValue = LibraryClient.unimplemented +} -extension LibraryClient { - static let failing: Self = .init( - initializeLogger: { .failing("\(Self.self).initializeLogger is unimplemented") }, - initializeWebImage: { .failing("\(Self.self).initializeWebImage is unimplemented") }, - clearWebImageDiskCache: { .failing("\(Self.self).clearWebImageDiskCache is unimplemented") }, - analyzeImageColors: { _ in .failing("\(Self.self).analyzeImageColors is unimplemented") }, - calculateWebImageDiskCacheSize: { .failing("\(Self.self).calculateWebImageDiskCacheSize is unimplemented") } - ) +extension DependencyValues { + var libraryClient: LibraryClient { + get { self[LibraryClientKey.self] } + set { self[LibraryClientKey.self] = newValue } + } } -#endif + +// MARK: Test extension LibraryClient { static let noop: Self = .init( initializeLogger: { .none }, @@ -110,4 +111,13 @@ extension LibraryClient { analyzeImageColors: { _ in .none }, calculateWebImageDiskCacheSize: { .none } ) + + static let unimplemented: Self = .init( + initializeLogger: XCTestDynamicOverlay.unimplemented("\(Self.self).initializeLogger"), + initializeWebImage: XCTestDynamicOverlay.unimplemented("\(Self.self).initializeWebImage"), + clearWebImageDiskCache: XCTestDynamicOverlay.unimplemented("\(Self.self).clearWebImageDiskCache"), + analyzeImageColors: XCTestDynamicOverlay.unimplemented("\(Self.self).analyzeImageColors"), + calculateWebImageDiskCacheSize: + XCTestDynamicOverlay.unimplemented("\(Self.self).calculateWebImageDiskCacheSize") + ) } diff --git a/EhPanda/App/Tools/Clients/LoggerClient.swift b/EhPanda/App/Tools/Clients/LoggerClient.swift index 6f22c8ee..ebeefb83 100644 --- a/EhPanda/App/Tools/Clients/LoggerClient.swift +++ b/EhPanda/App/Tools/Clients/LoggerClient.swift @@ -8,8 +8,8 @@ import ComposableArchitecture struct LoggerClient { - let info: (Any, Any?) -> Effect - let error: (Any, Any?) -> Effect + let info: (Any, Any?) -> EffectTask + let error: (Any, Any?) -> EffectTask } extension LoggerClient { @@ -27,20 +27,29 @@ extension LoggerClient { ) } -// MARK: Test -#if DEBUG -import XCTestDynamicOverlay +// MARK: API +enum LoggerClientKey: DependencyKey { + static let liveValue = LoggerClient.live + static let previewValue = LoggerClient.noop + static let testValue = LoggerClient.unimplemented +} -extension LoggerClient { - static let failing: Self = .init( - info: { .failing("\(Self.self).info(\($0), \(String(describing: $1))) is unimplemented") }, - error: { .failing("\(Self.self).error(\($0), \(String(describing: $1))) is unimplemented") } - ) +extension DependencyValues { + var loggerClient: LoggerClient { + get { self[LoggerClientKey.self] } + set { self[LoggerClientKey.self] = newValue } + } } -#endif + +// MARK: Test extension LoggerClient { static let noop: Self = .init( info: { _, _ in .none }, error: { _, _ in .none } ) + + static let unimplemented: Self = .init( + info: XCTestDynamicOverlay.unimplemented("\(Self.self).info"), + error: XCTestDynamicOverlay.unimplemented("\(Self.self).error") + ) } diff --git a/EhPanda/App/Tools/Clients/UIApplicationClient.swift b/EhPanda/App/Tools/Clients/UIApplicationClient.swift index df705f47..97aef088 100644 --- a/EhPanda/App/Tools/Clients/UIApplicationClient.swift +++ b/EhPanda/App/Tools/Clients/UIApplicationClient.swift @@ -10,11 +10,11 @@ import Combine import ComposableArchitecture struct UIApplicationClient { - let openURL: (URL) -> Effect - let hideKeyboard: () -> Effect + let openURL: (URL) -> EffectTask + let hideKeyboard: () -> EffectTask let alternateIconName: () -> String? - let setAlternateIconName: (String?) -> Effect, Never> - let setUserInterfaceStyle: (UIUserInterfaceStyle) -> Effect + let setAlternateIconName: (String?) -> EffectTask> + let setUserInterfaceStyle: (UIUserInterfaceStyle) -> EffectTask } extension UIApplicationClient { @@ -51,13 +51,13 @@ extension UIApplicationClient { } } ) - func openSettings() -> Effect { + func openSettings() -> EffectTask { if let url = URL(string: UIApplication.openSettingsURLString) { return openURL(url) } return .none } - func openFileApp() -> Effect { + func openFileApp() -> EffectTask { if let dirPath = FileUtil.logsDirectoryURL?.path, let dirURL = URL(string: "shareddocuments://" + dirPath) { @@ -67,25 +67,21 @@ extension UIApplicationClient { } } -// MARK: Test -// swiftlint:disable line_length -#if DEBUG -import XCTestDynamicOverlay +// MARK: API +enum UIApplicationClientKey: DependencyKey { + static let liveValue = UIApplicationClient.live + static let previewValue = UIApplicationClient.noop + static let testValue = UIApplicationClient.unimplemented +} -extension UIApplicationClient { - static let failing: Self = .init( - openURL: { .failing("\(Self.self).openURL(\($0)) is unimplemented") }, - hideKeyboard: { .failing("\(Self.self).hideKeyboard is unimplemented") }, - alternateIconName: { - XCTFail("\(Self.self).alternateIconName is unimplemented") - return nil - }, - setAlternateIconName: { .failing("\(Self.self).setAlternateIconName(\(String(describing: $0))) is unimplemented") }, - setUserInterfaceStyle: { .failing("\(Self.self).setUserInterfaceStyle(\($0)) is unimplemented") } - ) +extension DependencyValues { + var uiApplicationClient: UIApplicationClient { + get { self[UIApplicationClientKey.self] } + set { self[UIApplicationClientKey.self] = newValue } + } } -#endif -// swiftlint:enable line_length + +// MARK: Test extension UIApplicationClient { static let noop: Self = .init( openURL: { _ in .none}, @@ -94,4 +90,12 @@ extension UIApplicationClient { setAlternateIconName: { _ in .none }, setUserInterfaceStyle: { _ in .none } ) + + static let unimplemented: Self = .init( + openURL: XCTestDynamicOverlay.unimplemented("\(Self.self).openURL"), + hideKeyboard: XCTestDynamicOverlay.unimplemented("\(Self.self).hideKeyboard"), + alternateIconName: XCTestDynamicOverlay.unimplemented("\(Self.self).alternateIconName"), + setAlternateIconName: XCTestDynamicOverlay.unimplemented("\(Self.self).importTagTranslator"), + setUserInterfaceStyle: XCTestDynamicOverlay.unimplemented("\(Self.self).setUserInterfaceStyle") + ) } diff --git a/EhPanda/App/Tools/Clients/URLClient.swift b/EhPanda/App/Tools/Clients/URLClient.swift index d6487e14..30ea498c 100644 --- a/EhPanda/App/Tools/Clients/URLClient.swift +++ b/EhPanda/App/Tools/Clients/URLClient.swift @@ -6,7 +6,7 @@ // import SwiftUI -import ComposableArchitecture +import Dependencies struct URLClient { let checkIfHandleable: (URL) -> Bool @@ -67,3 +67,32 @@ extension URLClient { return (isGalleryImageURL, pageIndex, commentID) } } + +// MARK: API +enum URLClientKey: DependencyKey { + static let liveValue = URLClient.live + static let previewValue = URLClient.noop + static let testValue = URLClient.unimplemented +} + +extension DependencyValues { + var urlClient: URLClient { + get { self[URLClientKey.self] } + set { self[URLClientKey.self] = newValue } + } +} + +// MARK: Test +extension URLClient { + static let noop: Self = .init( + checkIfHandleable: { _ in false }, + checkIfMPVURL: { _ in false }, + parseGalleryID: { _ in .init() } + ) + + static let unimplemented: Self = .init( + checkIfHandleable: XCTestDynamicOverlay.unimplemented("\(Self.self).checkIfHandleable"), + checkIfMPVURL: XCTestDynamicOverlay.unimplemented("\(Self.self).checkIfMPVURL"), + parseGalleryID: XCTestDynamicOverlay.unimplemented("\(Self.self).parseGalleryID") + ) +} diff --git a/EhPanda/App/Tools/Clients/UserDefaultsClient.swift b/EhPanda/App/Tools/Clients/UserDefaultsClient.swift index b9f3e798..c403f278 100644 --- a/EhPanda/App/Tools/Clients/UserDefaultsClient.swift +++ b/EhPanda/App/Tools/Clients/UserDefaultsClient.swift @@ -5,10 +5,11 @@ // Created by 荒木辰造 on R 4/01/02. // +import Foundation import ComposableArchitecture struct UserDefaultsClient { - let setValue: (Any, AppUserDefaults) -> Effect + let setValue: (Any, AppUserDefaults) -> EffectTask } extension UserDefaultsClient { @@ -24,3 +25,28 @@ extension UserDefaultsClient { UserDefaultsUtil.value(forKey: key) } } + +// MARK: API +enum UserDefaultsClientKey: DependencyKey { + static let liveValue = UserDefaultsClient.live + static let previewValue = UserDefaultsClient.noop + static let testValue = UserDefaultsClient.unimplemented +} + +extension DependencyValues { + var userDefaultsClient: UserDefaultsClient { + get { self[UserDefaultsClientKey.self] } + set { self[UserDefaultsClientKey.self] = newValue } + } +} + +// MARK: Test +extension UserDefaultsClient { + static let noop: Self = .init( + setValue: { _, _ in .none } + ) + + static let unimplemented: Self = .init( + setValue: XCTestDynamicOverlay.unimplemented("\(Self.self).setValue") + ) +} diff --git a/EhPanda/App/Tools/Defaults.swift b/EhPanda/App/Tools/Defaults.swift index 8d5070d5..889d25e8 100644 --- a/EhPanda/App/Tools/Defaults.swift +++ b/EhPanda/App/Tools/Defaults.swift @@ -71,6 +71,7 @@ struct Defaults { static var host: Foundation.URL { AppUtil.galleryHost == .exhentai ? exhentai : ehentai } static let ehentai: Foundation.URL = .init(string: "https://e-hentai.org/").forceUnwrapped static let exhentai: Foundation.URL = .init(string: "https://exhentai.org/").forceUnwrapped + static let sexhentai: Foundation.URL = .init(string: "https://s.exhentai.org/").forceUnwrapped static let torrentDownload: Foundation.URL = .init(string: "https://ehgt.org/g/t.png").forceUnwrapped static let torrentDownloadInvalid: Foundation.URL = .init(string: "https://ehgt.org/g/td.png").forceUnwrapped diff --git a/EhPanda/App/Tools/Extensions/Extensions.swift b/EhPanda/App/Tools/Extensions/Extensions.swift index 7b49ca9c..b82d300d 100644 --- a/EhPanda/App/Tools/Extensions/Extensions.swift +++ b/EhPanda/App/Tools/Extensions/Extensions.swift @@ -67,6 +67,7 @@ extension URL { } func appending(queryItems: [URLQueryItem]) -> URL { + guard !queryItems.isEmpty else { return self } var components: URLComponents = .init( url: self, resolvingAgainstBaseURL: false ) diff --git a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift b/EhPanda/App/Tools/Extensions/Reducer_Extension.swift index ce1780d5..03d157fa 100644 --- a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift +++ b/EhPanda/App/Tools/Extensions/Reducer_Extension.swift @@ -8,49 +8,71 @@ import SwiftUI import ComposableArchitecture -// MARK: Logging -extension Reducer { - func logging() -> Self { - .init { state, action, environment in - Logger.info(action) - return run(&state, action, environment) +extension ReducerProtocol { + func haptics( + unwrapping enum: @escaping (State) -> Enum?, + case casePath: CasePath, + hapticsClient: HapticsClient, + style: UIImpactFeedbackGenerator.FeedbackStyle = .light + ) -> some ReducerProtocol { + onBecomeNonNil(unwrapping: `enum`, case: casePath) { _, _ in + .fireAndForget({ hapticsClient.generateFeedback(style) }) } } -} -// MARK: Haptic -extension Reducer { - static func recurse(_ reducer: @escaping (Reducer) -> Reducer) -> Reducer { - var `self`: Reducer! - self = Reducer { state, action, environment in - reducer(self).run(&state, action, environment) - } - return self - } - func onBecomeNonNil( + private func onBecomeNonNil( unwrapping enum: @escaping (State) -> Enum?, case casePath: CasePath, - perform additionalEffects: @escaping (inout State, Action, Environment) - -> Effect - ) -> Self { - .init { state, action, environment in + perform additionalEffects: @escaping (inout State, Action) -> EffectTask + ) -> some ReducerProtocol { + Reduce { state, action in let previousCase = Binding.constant(`enum`(state)).case(casePath).wrappedValue - let effects = run(&state, action, environment) + let effects = reduce(into: &state, action: action) let currentCase = Binding.constant(`enum`(state)).case(casePath).wrappedValue return previousCase == nil && currentCase != nil - ? .merge(effects, additionalEffects(&state, action, environment)) + ? .merge(effects, additionalEffects(&state, action)) : effects } } - func haptics( - unwrapping enum: @escaping (State) -> Enum?, - case casePath: CasePath, - hapticClient: @escaping (Environment) -> HapticClient, - style: UIImpactFeedbackGenerator.FeedbackStyle = .light - ) -> Self { - onBecomeNonNil(unwrapping: `enum`, case: casePath) { - hapticClient($2).generateFeedback(style).fireAndForget() +} + +// MARK: Recurse +struct RecurseReducer: ReducerProtocol +where State == Base.State, Action == Base.Action { + let base: (Reduce) -> Base + + public init(@ReducerBuilder base: @escaping (Reduce) -> Base) { + self.base = base + } + + public var body: some ReducerProtocol { + var `self`: Reduce! + self = Reduce { state, action in + base(self).reduce(into: &state, action: action) + } + return self + } +} + +// MARK: Logging +struct LoggingReducer: ReducerProtocol +where State == Base.State, Action == Base.Action { + let base: Base + + init(@ReducerBuilder base: () -> Base) { + self.base = base() + } + + @ReducerBuilder + var body: some ReducerProtocol { + Reduce { state, action in + if case .setting(.binding(let bindingAction)) = action as? AppReducer.Action { + Logger.info("setting(EhPanda.SettingReducer.Action.\(bindingAction.customDumpMirror.subjectType)") + } else { + Logger.info(action) + } + return base.reduce(into: &state, action: action) } } } diff --git a/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift b/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift index fa3113aa..45c35763 100644 --- a/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift +++ b/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift @@ -34,7 +34,9 @@ extension NavigationLink { } extension View { - @ViewBuilder func sheet( + @MainActor + @ViewBuilder + func sheet( unwrapping enum: Binding, case casePath: CasePath, onDismiss: (() -> Void)? = nil, diff --git a/EhPanda/App/Tools/Parser.swift b/EhPanda/App/Tools/Parser.swift index 9b02dc0b..3250934d 100644 --- a/EhPanda/App/Tools/Parser.swift +++ b/EhPanda/App/Tools/Parser.swift @@ -1109,7 +1109,7 @@ extension Parser { let form = tmpForm else { throw AppError.parseFailed } // swiftlint:disable line_length - var ehProfiles = [EhProfile](); var isCapableOfCreatingNewProfile: Bool?; var capableLoadThroughHathSetting: EhSetting.LoadThroughHathSetting?; var capableImageResolution: EhSetting.ImageResolution?; var capableSearchResultCount: EhSetting.SearchResultCount?; var capableThumbnailConfigSize: EhSetting.ThumbnailSize?; var capableThumbnailConfigRowCount: EhSetting.ThumbnailRowCount?; var loadThroughHathSetting: EhSetting.LoadThroughHathSetting?; var browsingCountry: EhSetting.BrowsingCountry?; var imageResolution: EhSetting.ImageResolution?; var imageSizeWidth: Float?; var imageSizeHeight: Float?; var galleryName: EhSetting.GalleryName?; var literalBrowsingCountry: String?; var archiverBehavior: EhSetting.ArchiverBehavior?; var displayMode: EhSetting.DisplayMode?; var disabledCategories = [Bool](); var favoriteCategories = [String](); var favoritesSortOrder: EhSetting.FavoritesSortOrder?; var ratingsColor: String?; var excludedNamespaces = [Bool](); var tagFilteringThreshold: Float?; var tagWatchingThreshold: Float?; var showFilteredRemovalCount: Bool?; var excludedLanguages = [Bool](); var excludedUploaders: String?; var searchResultCount: EhSetting.SearchResultCount?; var thumbnailLoadTiming: EhSetting.ThumbnailLoadTiming?; var thumbnailConfigSize: EhSetting.ThumbnailSize?; var thumbnailConfigRows: EhSetting.ThumbnailRowCount?; var thumbnailScaleFactor: Float?; var viewportVirtualWidth: Float?; var commentsSortOrder: EhSetting.CommentsSortOrder?; var commentVotesShowTiming: EhSetting.CommentVotesShowTiming?; var tagsSortOrder: EhSetting.TagsSortOrder?; var galleryShowPageNumbers: Bool?; var useOriginalImages: Bool?; var useMultiplePageViewer: Bool?; var multiplePageViewerStyle: EhSetting.MultiplePageViewerStyle?; var multiplePageViewerShowThumbnailPane: Bool? + var ehProfiles = [EhProfile](); var isCapableOfCreatingNewProfile: Bool?; var capableLoadThroughHathSetting: EhSetting.LoadThroughHathSetting?; var capableImageResolution: EhSetting.ImageResolution?; var capableSearchResultCount: EhSetting.SearchResultCount?; var capableThumbnailConfigSize: EhSetting.ThumbnailSize?; var capableThumbnailConfigRowCount: EhSetting.ThumbnailRowCount?; var loadThroughHathSetting: EhSetting.LoadThroughHathSetting?; var browsingCountry: EhSetting.BrowsingCountry?; var imageResolution: EhSetting.ImageResolution?; var imageSizeWidth: Float?; var imageSizeHeight: Float?; var galleryName: EhSetting.GalleryName?; var literalBrowsingCountry: String?; var archiverBehavior: EhSetting.ArchiverBehavior?; var displayMode: EhSetting.DisplayMode?; var showSearchRangeIndicator: Bool?; var disabledCategories = [Bool](); var favoriteCategories = [String](); var favoritesSortOrder: EhSetting.FavoritesSortOrder?; var ratingsColor: String?; var tagFilteringThreshold: Float?; var tagWatchingThreshold: Float?; var showFilteredRemovalCount: Bool?; var excludedLanguages = [Bool](); var excludedUploaders: String?; var searchResultCount: EhSetting.SearchResultCount?; var thumbnailLoadTiming: EhSetting.ThumbnailLoadTiming?; var thumbnailConfigSize: EhSetting.ThumbnailSize?; var thumbnailConfigRows: EhSetting.ThumbnailRowCount?; var thumbnailScaleFactor: Float?; var viewportVirtualWidth: Float?; var commentsSortOrder: EhSetting.CommentsSortOrder?; var commentVotesShowTiming: EhSetting.CommentVotesShowTiming?; var tagsSortOrder: EhSetting.TagsSortOrder?; var galleryShowPageNumbers: Bool?; var useOriginalImages: Bool?; var useMultiplePageViewer: Bool?; var multiplePageViewerStyle: EhSetting.MultiplePageViewerStyle?; var multiplePageViewerShowThumbnailPane: Bool? // swiftlint:enable line_length ehProfiles = parseSelections(node: profileOuter, name: "profile_set") @@ -1166,6 +1166,9 @@ extension Parser { if optouter.at_xpath("//input [@name='dm']") != nil { displayMode = parseEnum(node: optouter, name: "dm") } + if optouter.at_xpath("//input [@name='pp']") != nil { + showSearchRangeIndicator = parseInt(node: optouter, name: "pp") == 0 + } if optouter.at_xpath("//div [@id='catsel']") != nil { disabledCategories = Array(0...9) .map { "ct_\(EhSetting.categoryNames[$0])" } @@ -1181,10 +1184,6 @@ extension Parser { if optouter.at_xpath("//input [@name='ru']") != nil { ratingsColor = parseString(node: optouter, name: "ru") ?? "" } - if optouter.at_xpath("//div [@id='nssel']") != nil { - excludedNamespaces = Array(1...11).map { "xn_\($0)" } - .compactMap { parseCheckBoxBool(node: optouter, name: $0) } - } if optouter.at_xpath("//input [@name='ft']") != nil { tagFilteringThreshold = Float(parseString(node: optouter, name: "ft") ?? "0") if tagFilteringThreshold == nil { tagFilteringThreshold = 0 } @@ -1254,10 +1253,10 @@ extension Parser { } // swiftlint:disable line_length - guard !ehProfiles.filter(\.isSelected).isEmpty, let isCapableOfCreatingNewProfile, let capableLoadThroughHathSetting, let capableImageResolution, let capableSearchResultCount, let capableThumbnailConfigSize, let capableThumbnailConfigRowCount, let loadThroughHathSetting, let browsingCountry, let literalBrowsingCountry, let imageResolution, let imageSizeWidth, let imageSizeHeight, let galleryName, let archiverBehavior, let displayMode, disabledCategories.count == 10, favoriteCategories.count == 10, let favoritesSortOrder, let ratingsColor, excludedNamespaces.count == 11, let tagFilteringThreshold, let tagWatchingThreshold, excludedLanguages.count == 50, let excludedUploaders, let searchResultCount, let thumbnailLoadTiming, let thumbnailConfigSize, let thumbnailConfigRows, let thumbnailScaleFactor, let viewportVirtualWidth, let commentsSortOrder, let commentVotesShowTiming, let tagsSortOrder, let galleryShowPageNumbers + guard !ehProfiles.filter(\.isSelected).isEmpty, let isCapableOfCreatingNewProfile, let capableLoadThroughHathSetting, let capableImageResolution, let capableSearchResultCount, let capableThumbnailConfigSize, let capableThumbnailConfigRowCount, let loadThroughHathSetting, let browsingCountry, let literalBrowsingCountry, let imageResolution, let imageSizeWidth, let imageSizeHeight, let galleryName, let archiverBehavior, let displayMode, let showSearchRangeIndicator, disabledCategories.count == 10, favoriteCategories.count == 10, let favoritesSortOrder, let ratingsColor, let tagFilteringThreshold, let tagWatchingThreshold, let showFilteredRemovalCount, excludedLanguages.count == 50, let excludedUploaders, let searchResultCount, let thumbnailLoadTiming, let thumbnailConfigSize, let thumbnailConfigRows, let thumbnailScaleFactor, let viewportVirtualWidth, let commentsSortOrder, let commentVotesShowTiming, let tagsSortOrder, let galleryShowPageNumbers else { throw AppError.parseFailed } - return EhSetting(ehProfiles: ehProfiles.sorted(), isCapableOfCreatingNewProfile: isCapableOfCreatingNewProfile, capableLoadThroughHathSetting: capableLoadThroughHathSetting, capableImageResolution: capableImageResolution, capableSearchResultCount: capableSearchResultCount, capableThumbnailConfigSize: capableThumbnailConfigSize, capableThumbnailConfigRowCount: capableThumbnailConfigRowCount, loadThroughHathSetting: loadThroughHathSetting, browsingCountry: browsingCountry, literalBrowsingCountry: literalBrowsingCountry, imageResolution: imageResolution, imageSizeWidth: imageSizeWidth, imageSizeHeight: imageSizeHeight, galleryName: galleryName, archiverBehavior: archiverBehavior, displayMode: displayMode, disabledCategories: disabledCategories, favoriteCategories: favoriteCategories, favoritesSortOrder: favoritesSortOrder, ratingsColor: ratingsColor, excludedNamespaces: excludedNamespaces, tagFilteringThreshold: tagFilteringThreshold, tagWatchingThreshold: tagWatchingThreshold, showFilteredRemovalCount: showFilteredRemovalCount, excludedLanguages: excludedLanguages, excludedUploaders: excludedUploaders, searchResultCount: searchResultCount, thumbnailLoadTiming: thumbnailLoadTiming, thumbnailConfigSize: thumbnailConfigSize, thumbnailConfigRows: thumbnailConfigRows, thumbnailScaleFactor: thumbnailScaleFactor, viewportVirtualWidth: viewportVirtualWidth, commentsSortOrder: commentsSortOrder, commentVotesShowTiming: commentVotesShowTiming, tagsSortOrder: tagsSortOrder, galleryShowPageNumbers: galleryShowPageNumbers, useOriginalImages: useOriginalImages, useMultiplePageViewer: useMultiplePageViewer, multiplePageViewerStyle: multiplePageViewerStyle, multiplePageViewerShowThumbnailPane: multiplePageViewerShowThumbnailPane + return EhSetting(ehProfiles: ehProfiles.sorted(), isCapableOfCreatingNewProfile: isCapableOfCreatingNewProfile, capableLoadThroughHathSetting: capableLoadThroughHathSetting, capableImageResolution: capableImageResolution, capableSearchResultCount: capableSearchResultCount, capableThumbnailConfigSize: capableThumbnailConfigSize, capableThumbnailConfigRowCount: capableThumbnailConfigRowCount, loadThroughHathSetting: loadThroughHathSetting, browsingCountry: browsingCountry, literalBrowsingCountry: literalBrowsingCountry, imageResolution: imageResolution, imageSizeWidth: imageSizeWidth, imageSizeHeight: imageSizeHeight, galleryName: galleryName, archiverBehavior: archiverBehavior, displayMode: displayMode, showSearchRangeIndicator: showSearchRangeIndicator, disabledCategories: disabledCategories, favoriteCategories: favoriteCategories, favoritesSortOrder: favoritesSortOrder, ratingsColor: ratingsColor, tagFilteringThreshold: tagFilteringThreshold, tagWatchingThreshold: tagWatchingThreshold, showFilteredRemovalCount: showFilteredRemovalCount, excludedLanguages: excludedLanguages, excludedUploaders: excludedUploaders, searchResultCount: searchResultCount, thumbnailLoadTiming: thumbnailLoadTiming, thumbnailConfigSize: thumbnailConfigSize, thumbnailConfigRows: thumbnailConfigRows, thumbnailScaleFactor: thumbnailScaleFactor, viewportVirtualWidth: viewportVirtualWidth, commentsSortOrder: commentsSortOrder, commentVotesShowTiming: commentVotesShowTiming, tagsSortOrder: tagsSortOrder, galleryShowPageNumbers: galleryShowPageNumbers, useOriginalImages: useOriginalImages, useMultiplePageViewer: useMultiplePageViewer, multiplePageViewerStyle: multiplePageViewerStyle, multiplePageViewerShowThumbnailPane: multiplePageViewerShowThumbnailPane ) // swiftlint:enable line_length } @@ -1500,7 +1499,7 @@ extension Parser { } // MARK: Profile - static func parseProfileIndex(doc: HTMLDocument) throws -> (Int?, Bool) { + static func parseProfileIndex(doc: HTMLDocument) throws -> VerifyEhProfileResponse { var profileNotFound = true var profileValue: Int? @@ -1515,7 +1514,7 @@ extension Parser { profileValue = Int(link["value"] ?? "") } - return (profileValue, profileNotFound) + return .init(profileValue: profileValue, isProfileNotFound: profileNotFound) } // MARK: CommentContent diff --git a/EhPanda/App/Tools/Utilities/AppUtil.swift b/EhPanda/App/Tools/Utilities/AppUtil.swift index 66729c4f..79f1b9a9 100644 --- a/EhPanda/App/Tools/Utilities/AppUtil.swift +++ b/EhPanda/App/Tools/Utilities/AppUtil.swift @@ -15,6 +15,15 @@ struct AppUtil { Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "null" } + private static let internalIsTesting = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + public static var isTesting: Bool { + #if DEBUG + internalIsTesting + #else + false + #endif + } + static var galleryHost: GalleryHost { let rawValue: String? = UserDefaultsUtil.value(forKey: .galleryHost) return GalleryHost(rawValue: rawValue ?? "") ?? .ehentai diff --git a/EhPanda/App/Tools/Utilities/CookiesUtil.swift b/EhPanda/App/Tools/Utilities/CookieUtil.swift similarity index 83% rename from EhPanda/App/Tools/Utilities/CookiesUtil.swift rename to EhPanda/App/Tools/Utilities/CookieUtil.swift index 717bda3f..a07f7ebe 100644 --- a/EhPanda/App/Tools/Utilities/CookiesUtil.swift +++ b/EhPanda/App/Tools/Utilities/CookieUtil.swift @@ -1,5 +1,5 @@ // -// CookiesUtil.swift +// CookieUtil.swift // EhPanda // // Created by 荒木辰造 on R 4/02/02. @@ -7,11 +7,11 @@ import Foundation -// MARK: Cookies -struct CookiesUtil { +// MARK: Cookie +struct CookieUtil { static var didLogin: Bool { - CookiesUtil.verify(for: Defaults.URL.ehentai, isEx: false) - || CookiesUtil.verify(for: Defaults.URL.exhentai, isEx: true) + CookieUtil.verify(for: Defaults.URL.ehentai, isEx: false) + || CookieUtil.verify(for: Defaults.URL.exhentai, isEx: true) } static func verify(for url: URL, isEx: Bool) -> Bool { diff --git a/EhPanda/App/Tools/Utilities/HapticUtil.swift b/EhPanda/App/Tools/Utilities/HapticsUtil.swift similarity index 97% rename from EhPanda/App/Tools/Utilities/HapticUtil.swift rename to EhPanda/App/Tools/Utilities/HapticsUtil.swift index 3cf825eb..e436b136 100644 --- a/EhPanda/App/Tools/Utilities/HapticUtil.swift +++ b/EhPanda/App/Tools/Utilities/HapticsUtil.swift @@ -1,5 +1,5 @@ // -// HapticUtil.swift +// HapticsUtil.swift // EhPanda // // Created by 荒木辰造 on R 4/02/02. @@ -8,7 +8,7 @@ import SwiftUI import AudioToolbox -struct HapticUtil { +struct HapticsUtil { static func generateFeedback(style: UIImpactFeedbackGenerator.FeedbackStyle) { guard !isLegacyTapticEngine else { generateLegacyFeedback() diff --git a/EhPanda/App/de.lproj/Localizable.strings b/EhPanda/App/de.lproj/Localizable.strings index a9ed2e2b..4d8c07ea 100644 --- a/EhPanda/App/de.lproj/Localizable.strings +++ b/EhPanda/App/de.lproj/Localizable.strings @@ -139,8 +139,9 @@ "search_view.section.title.recently_searched" = "Recently searched"; "search_view.section.title.recently_seen" = "Recently seen"; "search_view.section.title.quick_search" = "Quick search"; -// Searchable prompt +// Searchable "searchable.prompt.filter" = "Filter"; +"searchable.title.matches_count" = "Found %d matches."; // MARK: QuickSearchView "quick_search_view.title.quick_search" = "Quick search"; @@ -159,7 +160,7 @@ "enum.setting_state_route.value.appearance" = "Oberfläche"; "enum.setting_state_route.value.reading" = "Am Lesen"; "enum.setting_state_route.value.laboratory" = "Experimentelles"; -"enum.setting_state_route.value.ehPanda" = "Über EhPanda"; +"enum.setting_state_route.value.about" = "Über EhPanda"; // MARK: AccountSettingView "account_setting_view.title.account" = "Konto"; @@ -254,15 +255,15 @@ "laboratory_setting_view.title.laboratory" = "Experimentelles"; "laboratory_setting_view.title.bypasses_SNI_filtering" = "SNI Filter umgehen"; -// MARK: EhPandaView -"ehPanda_view.title.ehPanda" = "EhPanda"; -"ehPanda_view.button.website" = "Website"; -"ehPanda_view.button.altStore_source" = "AltStore Quelle"; -"ehPanda_view.title.version" = "Version"; -"ehPanda_view.section.title.special_thanks" = "Special thanks"; -"ehPanda_view.section.title.code_level_contributors" = "Code-level contributors"; -"ehPanda_view.section.title.translation_contributors" = "Translation contributors"; -"ehPanda_view.section.title.acknowledgements" = "OK"; +// MARK: AboutView +"about_view.title.ehPanda" = "EhPanda"; +"about_view.button.website" = "Website"; +"about_view.button.altStore_source" = "AltStore Quelle"; +"about_view.title.version" = "Version"; +"about_view.section.title.special_thanks" = "Special thanks"; +"about_view.section.title.code_level_contributors" = "Code-level contributors"; +"about_view.section.title.translation_contributors" = "Translation contributors"; +"about_view.section.title.acknowledgements" = "OK"; // MARK: DetailView "detail_view.button.read" = "Lesen"; @@ -442,6 +443,8 @@ "eh_setting_view.section.title.front_page_settings" = "Front Page Settings"; "eh_setting_view.title.display_mode" = "Display mode"; "eh_setting_view.description.display_mode" = "Which display mode would you like to use on the front and search pages?"; +"eh_setting_view.section.title.show_search_range_indicator" = "Search Range Indicator"; +"eh_setting_view.title.show_search_range_indicator" = "Show search range indicator"; "eh_setting_view.description.gallery_category" = "What categories would you like to show by default on the front page and in searches?"; // EhSetting.DisplayMode "enum.eh_setting.display_mode.value.compact" = "Compact"; @@ -463,9 +466,6 @@ "eh_setting_view.promt.ratings_color" = "RRGGB"; "eh_setting_view.description.ratings_color" = "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works."; -"eh_setting_view.section.title.tags_namespaces" = "Tag Namespaces"; -"eh_setting_view.description.tags_namespaces" = "If you want to exclude certain namespaces from a default tag search, you can check those below. Note that this does not prevent galleries with tags in these namespaces from appearing, it just makes it so that when searching tags, it will forego those namespaces."; - "eh_setting_view.section.title.tag_filtering_threshold" = "Tag Filtering Threshold"; "eh_setting_view.title.tag_filtering_threshold" = "Tag Filtering Threshold"; "eh_setting_view.description.tag_filtering_threshold" = "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999."; diff --git a/EhPanda/App/en.lproj/Constant.strings b/EhPanda/App/en.lproj/Constant.strings index f77b7e70..8418542a 100644 --- a/EhPanda/App/en.lproj/Constant.strings +++ b/EhPanda/App/en.lproj/Constant.strings @@ -12,87 +12,87 @@ "website.response.invalid_resolution" = "The requested gallery cannot be downloaded with the selected resolution."; "website.response.gallery_unavailable" = "This gallery has been removed or is unavailable."; -// MARK: EhPanda -"EhPanda.copyright" = "Copyright © 2022 EhPanda Team"; +// MARK: App +"app.copyright" = "Copyright © 2023 EhPanda Team"; -// Contacts -"EhPanda.contacts.link.website" = "https://ehpanda.app"; -"EhPanda.contacts.link.gitHub" = "https://github.com/EhPanda-Team/EhPanda"; -"EhPanda.contacts.link.discord" = "https://discord.gg/BSBE9FCBTq"; -"EhPanda.contacts.link.telegram" = "https://t.me/ehpanda"; -"EhPanda.contacts.link.altStore" = "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json"; -"EhPanda.contacts.text.gitHub" = "GitHub"; -"EhPanda.contacts.text.discord" = "Discord"; -"EhPanda.contacts.text.telegram" = "Telegram"; +// Contact +"app.contact.link.website" = "https://ehpanda.app"; +"app.contact.link.gitHub" = "https://github.com/EhPanda-Team/EhPanda"; +"app.contact.link.discord" = "https://discord.gg/BSBE9FCBTq"; +"app.contact.link.telegram" = "https://t.me/ehpanda"; +"app.contact.link.altStore" = "altstore://source?url=https://github.com/EhPanda-Team/EhPanda/raw/main/AltStore.json"; +"app.contact.text.gitHub" = "GitHub"; +"app.contact.text.discord" = "Discord"; +"app.contact.text.telegram" = "Telegram"; // Special thanks -"EhPanda.special_thanks.link.taylorlannister" = "https://github.com/taylorlannister"; -"EhPanda.special_thanks.link.luminescent_yq" = ""; -"EhPanda.special_thanks.link.caxerx" = "https://github.com/caxerx"; -"EhPanda.special_thanks.link.honjow" = "https://github.com/honjow"; -"EhPanda.special_thanks.text.taylorlannister" = "taylorlannister"; -"EhPanda.special_thanks.text.luminescent_yq" = "Luminescent_yq"; -"EhPanda.special_thanks.text.caxerx" = "caxerx"; -"EhPanda.special_thanks.text.honjow" = "honjow"; +"app.special_thanks.link.taylorlannister" = "https://github.com/taylorlannister"; +"app.special_thanks.link.luminescent_yq" = ""; +"app.special_thanks.link.caxerx" = "https://github.com/caxerx"; +"app.special_thanks.link.honjow" = "https://github.com/honjow"; +"app.special_thanks.text.taylorlannister" = "taylorlannister"; +"app.special_thanks.text.luminescent_yq" = "Luminescent_yq"; +"app.special_thanks.text.caxerx" = "caxerx"; +"app.special_thanks.text.honjow" = "honjow"; -// Code level contributors -"EhPanda.code_level_contributors.link.tatsuz0u" = "https://github.com/tatsuz0u"; -"EhPanda.code_level_contributors.link.chihchy" = "https://github.com/chihchy"; -"EhPanda.code_level_contributors.link.xioxin" = "https://github.com/xioxin"; -"EhPanda.code_level_contributors.link.leng-yue" = "https://github.com/leng-yue"; -"EhPanda.code_level_contributors.link.ethanChinCN" = "https://github.com/EthanChinCN"; -"EhPanda.code_level_contributors.text.tatsuz0u" = "Tatsuzo Araki"; -"EhPanda.code_level_contributors.text.chihchy" = "Chihchy"; -"EhPanda.code_level_contributors.text.xioxin" = "xioxin"; -"EhPanda.code_level_contributors.text.leng-yue" = "LengYue"; -"EhPanda.code_level_contributors.text.ethanChinCN" = "Ethan Chin"; +// Code level contributor +"app.code_level_contributor.link.tatsuz0u" = "https://github.com/tatsuz0u"; +"app.code_level_contributor.link.chihchy" = "https://github.com/chihchy"; +"app.code_level_contributor.link.xioxin" = "https://github.com/xioxin"; +"app.code_level_contributor.link.leng-yue" = "https://github.com/leng-yue"; +"app.code_level_contributor.link.ethanChinCN" = "https://github.com/EthanChinCN"; +"app.code_level_contributor.text.tatsuz0u" = "Tatsuzo Araki"; +"app.code_level_contributor.text.chihchy" = "Chihchy"; +"app.code_level_contributor.text.xioxin" = "xioxin"; +"app.code_level_contributor.text.leng-yue" = "LengYue"; +"app.code_level_contributor.text.ethanChinCN" = "Ethan Chin"; -// Translation contributors -"EhPanda.translation_contributors.link.tatsuz0u" = "https://github.com/tatsuz0u"; -"EhPanda.translation_contributors.link.nebulosa-cat" = "https://github.com/Nebulosa-Cat"; -"EhPanda.translation_contributors.link.paulHaeussler" = "https://github.com/PaulHaeussler"; -"EhPanda.translation_contributors.link.caxerx" = "https://github.com/caxerx"; -"EhPanda.translation_contributors.link.nyaanim" = "https://github.com/nyaanim"; -"EhPanda.translation_contributors.text.tatsuz0u" = "Tatsuzo Araki"; -"EhPanda.translation_contributors.text.nebulosa-cat" = "雲豹 ΦωΦ"; -"EhPanda.translation_contributors.text.paulHaeussler" = "PaulHaeussler"; -"EhPanda.translation_contributors.text.caxerx" = "caxerx"; -"EhPanda.translation_contributors.text.nyaanim" = "nyaanim"; +// Translation contributor +"app.translation_contributor.link.tatsuz0u" = "https://github.com/tatsuz0u"; +"app.translation_contributor.link.nebulosa-cat" = "https://github.com/Nebulosa-Cat"; +"app.translation_contributor.link.paulHaeussler" = "https://github.com/PaulHaeussler"; +"app.translation_contributor.link.caxerx" = "https://github.com/caxerx"; +"app.translation_contributor.link.nyaanim" = "https://github.com/nyaanim"; +"app.translation_contributor.text.tatsuz0u" = "Tatsuzo Araki"; +"app.translation_contributor.text.nebulosa-cat" = "雲豹 ΦωΦ"; +"app.translation_contributor.text.paulHaeussler" = "PaulHaeussler"; +"app.translation_contributor.text.caxerx" = "caxerx"; +"app.translation_contributor.text.nyaanim" = "nyaanim"; -// Acknowledgements links -"EhPanda.acknowledgements.link.kanna" = "https://github.com/tid-kijyun/Kanna"; -"EhPanda.acknowledgements.link.swiftGen" = "https://github.com/SwiftGen/SwiftGen"; -"EhPanda.acknowledgements.link.alertKit" = "https://github.com/rebeloper/AlertKit"; -"EhPanda.acknowledgements.link.colorful" = "https://github.com/Co2333/Colorful"; -"EhPanda.acknowledgements.link.filePicker" = "https://github.com/markrenaud/FilePicker"; -"EhPanda.acknowledgements.link.kingfisher" = "https://github.com/onevcat/Kingfisher"; -"EhPanda.acknowledgements.link.swiftUIPager" = "https://github.com/fermoya/SwiftUIPager"; -"EhPanda.acknowledgements.link.swiftyBeaver" = "https://github.com/SwiftyBeaver/SwiftyBeaver"; -"EhPanda.acknowledgements.link.waterfallGrid" = "https://github.com/paololeonardi/WaterfallGrid"; -"EhPanda.acknowledgements.link.swiftyOpenCC" = "https://github.com/ddddxxx/SwiftyOpenCC"; -"EhPanda.acknowledgements.link.uiImageColors" = "https://github.com/jathu/UIImageColors"; -"EhPanda.acknowledgements.link.sfSafeSymbols" = "https://github.com/SFSafeSymbols/SFSafeSymbols"; -"EhPanda.acknowledgements.link.ttProgressHUD" = "https://github.com/honkmaster/TTProgressHUD"; -"EhPanda.acknowledgements.link.swiftUINavigation" = "https://github.com/pointfreeco/swiftui-navigation"; -"EhPanda.acknowledgements.link.swiftCommonMark" = "https://github.com/gonzalezreal/SwiftCommonMark"; -"EhPanda.acknowledgements.link.ehTagTranslationDatabase" = "https://github.com/EhTagTranslation/Database"; -"EhPanda.acknowledgements.link.tca" = "https://github.com/pointfreeco/swift-composable-architecture"; +// Acknowledgement link +"app.acknowledgement.link.kanna" = "https://github.com/tid-kijyun/Kanna"; +"app.acknowledgement.link.swiftGen" = "https://github.com/SwiftGen/SwiftGen"; +"app.acknowledgement.link.alertKit" = "https://github.com/rebeloper/AlertKit"; +"app.acknowledgement.link.colorful" = "https://github.com/Co2333/Colorful"; +"app.acknowledgement.link.filePicker" = "https://github.com/markrenaud/FilePicker"; +"app.acknowledgement.link.kingfisher" = "https://github.com/onevcat/Kingfisher"; +"app.acknowledgement.link.swiftUIPager" = "https://github.com/fermoya/SwiftUIPager"; +"app.acknowledgement.link.swiftyBeaver" = "https://github.com/SwiftyBeaver/SwiftyBeaver"; +"app.acknowledgement.link.waterfallGrid" = "https://github.com/paololeonardi/WaterfallGrid"; +"app.acknowledgement.link.swiftyOpenCC" = "https://github.com/ddddxxx/SwiftyOpenCC"; +"app.acknowledgement.link.uiImageColors" = "https://github.com/jathu/UIImageColors"; +"app.acknowledgement.link.sfSafeSymbols" = "https://github.com/SFSafeSymbols/SFSafeSymbols"; +"app.acknowledgement.link.ttProgressHUD" = "https://github.com/honkmaster/TTProgressHUD"; +"app.acknowledgement.link.swiftUINavigation" = "https://github.com/pointfreeco/swiftui-navigation"; +"app.acknowledgement.link.swiftCommonMark" = "https://github.com/gonzalezreal/SwiftCommonMark"; +"app.acknowledgement.link.ehTagTranslationDatabase" = "https://github.com/EhTagTranslation/Database"; +"app.acknowledgement.link.tca" = "https://github.com/pointfreeco/swift-composable-architecture"; -// Acknowledgements texts -"EhPanda.acknowledgements.text.kanna" = "Kanna"; -"EhPanda.acknowledgements.text.swiftGen" = "SwiftGen"; -"EhPanda.acknowledgements.text.alertKit" = "AlertKit"; -"EhPanda.acknowledgements.text.colorful" = "Colorful"; -"EhPanda.acknowledgements.text.filePicker" = "FilePicker"; -"EhPanda.acknowledgements.text.kingfisher" = "Kingfisher"; -"EhPanda.acknowledgements.text.swiftUIPager" = "SwiftUIPager"; -"EhPanda.acknowledgements.text.swiftyBeaver" = "SwiftyBeaver"; -"EhPanda.acknowledgements.text.waterfallGrid" = "WaterfallGrid"; -"EhPanda.acknowledgements.text.swiftyOpenCC" = "SwiftyOpenCC"; -"EhPanda.acknowledgements.text.uiImageColors" = "UIImageColors"; -"EhPanda.acknowledgements.text.sfSafeSymbols" = "SFSafeSymbols"; -"EhPanda.acknowledgements.text.ttProgressHUD" = "TTProgressHUD"; -"EhPanda.acknowledgements.text.swiftUINavigation" = "SwiftUI Navigation"; -"EhPanda.acknowledgements.text.swiftCommonMark" = "SwiftCommonMark"; -"EhPanda.acknowledgements.text.ehTagTranslationDatabase" = "EhTagTranslation/Database"; -"EhPanda.acknowledgements.text.tca" = "The Composable Architecture"; +// Acknowledgement text +"app.acknowledgement.text.kanna" = "Kanna"; +"app.acknowledgement.text.swiftGen" = "SwiftGen"; +"app.acknowledgement.text.alertKit" = "AlertKit"; +"app.acknowledgement.text.colorful" = "Colorful"; +"app.acknowledgement.text.filePicker" = "FilePicker"; +"app.acknowledgement.text.kingfisher" = "Kingfisher"; +"app.acknowledgement.text.swiftUIPager" = "SwiftUIPager"; +"app.acknowledgement.text.swiftyBeaver" = "SwiftyBeaver"; +"app.acknowledgement.text.waterfallGrid" = "WaterfallGrid"; +"app.acknowledgement.text.swiftyOpenCC" = "SwiftyOpenCC"; +"app.acknowledgement.text.uiImageColors" = "UIImageColors"; +"app.acknowledgement.text.sfSafeSymbols" = "SFSafeSymbols"; +"app.acknowledgement.text.ttProgressHUD" = "TTProgressHUD"; +"app.acknowledgement.text.swiftUINavigation" = "SwiftUI Navigation"; +"app.acknowledgement.text.swiftCommonMark" = "SwiftCommonMark"; +"app.acknowledgement.text.ehTagTranslationDatabase" = "EhTagTranslation/Database"; +"app.acknowledgement.text.tca" = "The Composable Architecture"; diff --git a/EhPanda/App/en.lproj/Localizable.strings b/EhPanda/App/en.lproj/Localizable.strings index 7b93af03..f01943a1 100644 --- a/EhPanda/App/en.lproj/Localizable.strings +++ b/EhPanda/App/en.lproj/Localizable.strings @@ -139,8 +139,9 @@ "search_view.section.title.recently_searched" = "Recently searched"; "search_view.section.title.recently_seen" = "Recently seen"; "search_view.section.title.quick_search" = "Quick search"; -// Searchable prompt +// Searchable "searchable.prompt.filter" = "Filter"; +"searchable.title.matches_count" = "Found %d matches."; // MARK: QuickSearchView "quick_search_view.title.quick_search" = "Quick search"; @@ -159,7 +160,7 @@ "enum.setting_state_route.value.appearance" = "Appearance"; "enum.setting_state_route.value.reading" = "Reading"; "enum.setting_state_route.value.laboratory" = "Laboratory"; -"enum.setting_state_route.value.ehPanda" = "About EhPanda"; +"enum.setting_state_route.value.about" = "About EhPanda"; // MARK: AccountSettingView "account_setting_view.title.account" = "Account"; @@ -254,15 +255,15 @@ "laboratory_setting_view.title.laboratory" = "Laboratory"; "laboratory_setting_view.title.bypasses_SNI_filtering" = "Bypasses SNI Filtering"; -// MARK: EhPandaView -"ehPanda_view.title.ehPanda" = "EhPanda"; -"ehPanda_view.button.website" = "Website"; -"ehPanda_view.button.altStore_source" = "AltStore source"; -"ehPanda_view.title.version" = "Version"; -"ehPanda_view.section.title.special_thanks" = "Special thanks"; -"ehPanda_view.section.title.code_level_contributors" = "Code-level contributors"; -"ehPanda_view.section.title.translation_contributors" = "Translation contributors"; -"ehPanda_view.section.title.acknowledgements" = "Acknowledgements"; +// MARK: AboutView +"about_view.title.ehPanda" = "EhPanda"; +"about_view.button.website" = "Website"; +"about_view.button.altStore_source" = "AltStore source"; +"about_view.title.version" = "Version"; +"about_view.section.title.special_thanks" = "Special thanks"; +"about_view.section.title.code_level_contributors" = "Code-level contributors"; +"about_view.section.title.translation_contributors" = "Translation contributors"; +"about_view.section.title.acknowledgements" = "Acknowledgements"; // MARK: DetailView "detail_view.button.read" = "Read"; @@ -442,6 +443,8 @@ "eh_setting_view.section.title.front_page_settings" = "Front Page Settings"; "eh_setting_view.title.display_mode" = "Display mode"; "eh_setting_view.description.display_mode" = "Which display mode would you like to use on the front and search pages?"; +"eh_setting_view.section.title.show_search_range_indicator" = "Search Range Indicator"; +"eh_setting_view.title.show_search_range_indicator" = "Show search range indicator"; "eh_setting_view.description.gallery_category" = "What categories would you like to show by default on the front page and in searches?"; // EhSetting.DisplayMode "enum.eh_setting.display_mode.value.compact" = "Compact"; @@ -463,9 +466,6 @@ "eh_setting_view.promt.ratings_color" = "RRGGB"; "eh_setting_view.description.ratings_color" = "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works."; -"eh_setting_view.section.title.tags_namespaces" = "Tag Namespaces"; -"eh_setting_view.description.tags_namespaces" = "If you want to exclude certain namespaces from a default tag search, you can check those below. Note that this does not prevent galleries with tags in these namespaces from appearing, it just makes it so that when searching tags, it will forego those namespaces."; - "eh_setting_view.section.title.tag_filtering_threshold" = "Tag Filtering Threshold"; "eh_setting_view.title.tag_filtering_threshold" = "Tag Filtering Threshold"; "eh_setting_view.description.tag_filtering_threshold" = "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999."; diff --git a/EhPanda/App/ja.lproj/Localizable.strings b/EhPanda/App/ja.lproj/Localizable.strings index 15e26795..6f5dff7e 100644 --- a/EhPanda/App/ja.lproj/Localizable.strings +++ b/EhPanda/App/ja.lproj/Localizable.strings @@ -139,8 +139,9 @@ "search_view.section.title.recently_searched" = "最近検索した項目"; "search_view.section.title.recently_seen" = "最近閲覧した項目"; "search_view.section.title.quick_search" = "クイック検索"; -// Searchable prompt +// Searchable "searchable.prompt.filter" = "フィルター"; +"searchable.title.matches_count" = "%d 件の該当項目"; // MARK: QuickSearchView "quick_search_view.title.quick_search" = "クイック検索"; @@ -159,7 +160,7 @@ "enum.setting_state_route.value.appearance" = "外観"; "enum.setting_state_route.value.reading" = "閲覧"; "enum.setting_state_route.value.laboratory" = "ラボ"; -"enum.setting_state_route.value.ehPanda" = "EhPanda について"; +"enum.setting_state_route.value.about" = "EhPanda について"; // MARK: AccountSettingView "account_setting_view.title.account" = "アカウント"; @@ -254,15 +255,15 @@ "laboratory_setting_view.title.laboratory" = "ラボ"; "laboratory_setting_view.title.bypasses_SNI_filtering" = "SNI フィルタリング回避"; -// MARK: EhPandaView -"ehPanda_view.title.ehPanda" = "EhPanda"; -"ehPanda_view.button.website" = "ウェブサイト"; -"ehPanda_view.button.altStore_source" = "AltStore ソース"; -"ehPanda_view.title.version" = "バージョン"; -"ehPanda_view.section.title.special_thanks" = "特別な感謝"; -"ehPanda_view.section.title.code_level_contributors" = "コードレベル貢献者"; -"ehPanda_view.section.title.translation_contributors" = "翻訳貢献者"; -"ehPanda_view.section.title.acknowledgements" = "謝辞"; +// MARK: AboutView +"about_view.title.ehPanda" = "EhPanda"; +"about_view.button.website" = "ウェブサイト"; +"about_view.button.altStore_source" = "AltStore ソース"; +"about_view.title.version" = "バージョン"; +"about_view.section.title.special_thanks" = "特別な感謝"; +"about_view.section.title.code_level_contributors" = "コードレベル貢献者"; +"about_view.section.title.translation_contributors" = "翻訳貢献者"; +"about_view.section.title.acknowledgements" = "謝辞"; // MARK: DetailView "detail_view.button.read" = "閲覧"; @@ -442,6 +443,8 @@ "eh_setting_view.section.title.front_page_settings" = "フロントページ設定"; "eh_setting_view.title.display_mode" = "表示モード"; "eh_setting_view.description.display_mode" = "フロント・検索ページで使う表示モードはどれにしますか?"; +"eh_setting_view.section.title.show_search_range_indicator" = "検索範囲インジケーター"; +"eh_setting_view.title.show_search_range_indicator" = "検索範囲インジケーターを表示"; "eh_setting_view.description.gallery_category" = "フロント・検索ページでどれらのカテゴリーのギャラリーを表示しますか?"; // EhSetting.DisplayMode "enum.eh_setting.display_mode.value.compact" = "コンパクト"; @@ -463,9 +466,6 @@ "eh_setting_view.promt.ratings_color" = "RRGGB"; "eh_setting_view.description.ratings_color" = "デフォルトでは、評価済みのギャラリーは 2 以下の評価に赤い星を使う、2.5 ~ 4 には緑、4.5 以上には青。下に色の組み合わせを入れることでこのルールをカスタマイズできます。一つの星の色は一つの文字で指定します。デフォルトの「RRGGB」は「一番目と二番目の星は赤(Red)、三番と四番は緑(Green)、五番は青(Blue)」を意味します。黄色(Yellow)も使用可能です。R・G・B・Yで組み合わせた五文字はどれも機能します。"; -"eh_setting_view.section.title.tags_namespaces" = "タグの名前空間"; -"eh_setting_view.description.tags_namespaces" = "下にある名前空間を削除仕様にすることで通常のタグ検索結果から排除できます。注意:排除された名前空間のタグを持つギャラリーが表示されなくなることはありません。"; - "eh_setting_view.section.title.tag_filtering_threshold" = "タグフィルタリングしきい値"; "eh_setting_view.title.tag_filtering_threshold" = "タグフィルタリングしきい値"; "eh_setting_view.description.tag_filtering_threshold" = "負の重み付きでマイタグに追加することでタグをソフトフィルタリングすることができます。もしあるギャラリーが持つタグの重み総和がこのしきい値より低ければ、そのギャラリーはフィルタリングされます。このしきい値はゼロから -9999 まで設定できます。"; diff --git a/EhPanda/App/ko.lproj/Localizable.strings b/EhPanda/App/ko.lproj/Localizable.strings index 225e822a..6f42c533 100644 --- a/EhPanda/App/ko.lproj/Localizable.strings +++ b/EhPanda/App/ko.lproj/Localizable.strings @@ -139,8 +139,9 @@ "search_view.section.title.recently_searched" = "Recently searched"; "search_view.section.title.recently_seen" = "Recently seen"; "search_view.section.title.quick_search" = "빠른 검색"; -// Searchable prompt +// Searchable "searchable.prompt.filter" = "Filter"; +"searchable.title.matches_count" = "Found %d matches."; // MARK: QuickSearchView "quick_search_view.title.quick_search" = "빠른 검색"; @@ -159,7 +160,7 @@ "enum.setting_state_route.value.appearance" = "외관"; "enum.setting_state_route.value.reading" = "읽기"; "enum.setting_state_route.value.laboratory" = "실험실"; -"enum.setting_state_route.value.ehPanda" = "EhPanda 정보"; +"enum.setting_state_route.value.about" = "EhPanda 정보"; // MARK: AccountSettingView "account_setting_view.title.account" = "계정"; @@ -254,15 +255,15 @@ "laboratory_setting_view.title.laboratory" = "실험실"; "laboratory_setting_view.title.bypasses_SNI_filtering" = "SNI 차단 우회"; -// MARK: EhPandaView -"ehPanda_view.title.ehPanda" = "EhPanda"; -"ehPanda_view.button.website" = "웹사이트"; -"ehPanda_view.button.altStore_source" = "AltStore 소스"; -"ehPanda_view.title.version" = "버전"; -"ehPanda_view.section.title.special_thanks" = "Special thanks"; -"ehPanda_view.section.title.code_level_contributors" = "Code-level contributors"; -"ehPanda_view.section.title.translation_contributors" = "Translation contributors"; -"ehPanda_view.section.title.acknowledgements" = "도움을 주신 분들"; +// MARK: AboutView +"about_view.title.ehPanda" = "EhPanda"; +"about_view.button.website" = "웹사이트"; +"about_view.button.altStore_source" = "AltStore 소스"; +"about_view.title.version" = "버전"; +"about_view.section.title.special_thanks" = "Special thanks"; +"about_view.section.title.code_level_contributors" = "Code-level contributors"; +"about_view.section.title.translation_contributors" = "Translation contributors"; +"about_view.section.title.acknowledgements" = "도움을 주신 분들"; // MARK: DetailView "detail_view.button.read" = "읽기"; @@ -442,6 +443,8 @@ "eh_setting_view.section.title.front_page_settings" = "프론트 페이지 설정"; "eh_setting_view.title.display_mode" = "표시방식"; "eh_setting_view.description.display_mode" = "프론트와 검색 페이지에서 사용할 디스플레이 모드를 선택하세요."; +"eh_setting_view.section.title.show_search_range_indicator" = "Search Range Indicator"; +"eh_setting_view.title.show_search_range_indicator" = "Show search range indicator"; "eh_setting_view.description.gallery_category" = "프론트와 검색 페이지에서 어떤 카테고리가 보여지도록 할까요?"; // EhSetting.DisplayMode "enum.eh_setting.display_mode.value.compact" = "Compact"; @@ -463,9 +466,6 @@ "eh_setting_view.promt.ratings_color" = "RRGGB"; "eh_setting_view.description.ratings_color" = "기본적으로 등급을 매긴 갤러리는 별 2개 이하의 등급에 대해 빨간색, 2.5~4개의 등급에 대해 녹색, 4.5~5개의 등급에 대해 파란색 별로 표시되어요. 아래에 원하는 색상 조합을 입력하여 사용자 정의할 수 있어요. 각 문자는 별 하나를 표현해요. 기본 RRGGB는 첫 번째와 두 번째 별의 경우 R(ed), 세 번째와 네 번째 별의 경우 G(reen), 다섯 번째 별의 경우 B(lue)를 의미해요. 일반 별에 (Y)ellow를 사용할 수도 있어요. 모든 5글자의 R/G/B/Y 콤보가 작동해요."; -"eh_setting_view.section.title.tags_namespaces" = "태그 네임스페이스"; -"eh_setting_view.description.tags_namespaces" = "기본 태그 검색에서 특정 네임스페이스를 제외하려면 아래 네임스페이스들을 확인해주세요. 이렇게 해도 이러한 네임스페이스에 태그가 있는 갤러리가 나타나지 않고 태그를 검색할 때 해당 네임스페이스가 표시되지 않도록 할 수 있어요."; - "eh_setting_view.section.title.tag_filtering_threshold" = "태그 필터링 임계값"; "eh_setting_view.title.tag_filtering_threshold" = "태그 필터링 임계값"; "eh_setting_view.description.tag_filtering_threshold" = "마이너스 가중치로 My Tags에 추가하여 태그를 소프트 필터할 수 있어요. 갤러리에 이 값 이하의 가중치를 추가하는 태그가 있으면 보기에서 필터링되어요. 이 임계값은 0과 -9999 사이에서 설정할 수 있어요."; diff --git a/EhPanda/App/zh-Hans.lproj/Localizable.strings b/EhPanda/App/zh-Hans.lproj/Localizable.strings index d75212cd..75a7e1b7 100644 --- a/EhPanda/App/zh-Hans.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hans.lproj/Localizable.strings @@ -139,8 +139,9 @@ "search_view.section.title.recently_searched" = "最近搜索"; "search_view.section.title.recently_seen" = "最近看过"; "search_view.section.title.quick_search" = "快速搜索"; -// Searchable prompt +// Searchable "searchable.prompt.filter" = "筛选"; +"searchable.title.matches_count" = "找到 %d 项结果"; // MARK: QuickSearchView "quick_search_view.title.quick_search" = "快速搜索"; @@ -159,7 +160,7 @@ "enum.setting_state_route.value.appearance" = "外观"; "enum.setting_state_route.value.reading" = "阅读"; "enum.setting_state_route.value.laboratory" = "实验室"; -"enum.setting_state_route.value.ehPanda" = "关于 EhPanda"; +"enum.setting_state_route.value.about" = "关于 EhPanda"; // MARK: AccountSettingView "account_setting_view.title.account" = "账户"; @@ -254,15 +255,15 @@ "laboratory_setting_view.title.laboratory" = "实验室"; "laboratory_setting_view.title.bypasses_SNI_filtering" = "域前置绕过 SNI 阻断"; -// MARK: EhPandaView -"ehPanda_view.title.ehPanda" = "EhPanda"; -"ehPanda_view.button.website" = "网站"; -"ehPanda_view.button.altStore_source" = "AltStore 源"; -"ehPanda_view.title.version" = "版本"; -"ehPanda_view.section.title.special_thanks" = "特别致谢"; -"ehPanda_view.section.title.code_level_contributors" = "代码级贡献者"; -"ehPanda_view.section.title.translation_contributors" = "翻译贡献者"; -"ehPanda_view.section.title.acknowledgements" = "致谢"; +// MARK: AboutView +"about_view.title.ehPanda" = "EhPanda"; +"about_view.button.website" = "网站"; +"about_view.button.altStore_source" = "AltStore 源"; +"about_view.title.version" = "版本"; +"about_view.section.title.special_thanks" = "特别致谢"; +"about_view.section.title.code_level_contributors" = "代码级贡献者"; +"about_view.section.title.translation_contributors" = "翻译贡献者"; +"about_view.section.title.acknowledgements" = "致谢"; // MARK: DetailView "detail_view.button.read" = "阅读"; @@ -442,6 +443,8 @@ "eh_setting_view.section.title.front_page_settings" = "扉页设置"; "eh_setting_view.title.display_mode" = "显示样式"; "eh_setting_view.description.display_mode" = "你希望在扉页和搜索页显示哪种样式?"; +"eh_setting_view.section.title.show_search_range_indicator" = "搜索范围指示器"; +"eh_setting_view.title.show_search_range_indicator" = "显示搜索范围指示器"; "eh_setting_view.description.gallery_category" = "你希望在扉页和搜索页看到哪些类别?"; // EhSetting.DisplayMode "enum.eh_setting.display_mode.value.compact" = "紧凑"; @@ -463,9 +466,6 @@ "eh_setting_view.promt.ratings_color" = "RRGGB"; "eh_setting_view.description.ratings_color" = "默认设置下,你评为 2 星及以下的画廊显示为红星,2.5 ~ 4 星显示为绿星,4.5 ~ 5 星显示为蓝星。你可以将其设定为其它颜色组合。每一个字幕代表一颗星, 默认的 RRGGB 表示第一第二颗星显示为红色 R(ed),第三第四颗星显示是绿色 G(reen),第五颗星显示为蓝色 B(lue)。你也可以使用黄色 (Y)ellow,R/G/B/Y 任何五个组合都是有效的。"; -"eh_setting_view.section.title.tags_namespaces" = "标签命名空间"; -"eh_setting_view.description.tags_namespaces" = "如果要从默认标签搜索中排除某些命名空间,可以将以下内容标记为删除样式。注意:这不会阻止带有这些命名空间中标签的画廊出现,它只是在搜索标签时排除这些命名空间。"; - "eh_setting_view.section.title.tag_filtering_threshold" = "标签筛选阈值"; "eh_setting_view.title.tag_filtering_threshold" = "标签筛选阈值"; "eh_setting_view.description.tag_filtering_threshold" = "你可以通过将标签加入“我的标签”并设置一个负权重来软过滤它们。如果一个作品所有的标签权重之和低于设定值,此作品将从视图中被过滤。这个值可以设定为 0 ~ -9999。"; diff --git a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings index 745e10de..13fecb7d 100644 --- a/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-HK.lproj/Localizable.strings @@ -139,8 +139,9 @@ "search_view.section.title.recently_searched" = "Recently searched"; "search_view.section.title.recently_seen" = "Recently seen"; "search_view.section.title.quick_search" = "快速搜尋"; -// Searchable prompt +// Searchable "searchable.prompt.filter" = "篩選"; +"searchable.title.matches_count" = "找到 %d 項結果"; // MARK: QuickSearchView "quick_search_view.title.quick_search" = "快速搜尋"; @@ -159,7 +160,7 @@ "enum.setting_state_route.value.appearance" = "外觀"; "enum.setting_state_route.value.reading" = "閱讀"; "enum.setting_state_route.value.laboratory" = "實驗室"; -"enum.setting_state_route.value.ehPanda" = "關於 EhPanda"; +"enum.setting_state_route.value.about" = "關於 EhPanda"; // MARK: AccountSettingView "account_setting_view.title.account" = "帳號"; @@ -254,15 +255,15 @@ "laboratory_setting_view.title.laboratory" = "實驗室"; "laboratory_setting_view.title.bypasses_SNI_filtering" = "域前置繞過 SNI 阻斷"; -// MARK: EhPandaView -"ehPanda_view.title.ehPanda" = "EhPanda"; -"ehPanda_view.button.website" = "網站"; -"ehPanda_view.button.altStore_source" = "AltStore 源"; -"ehPanda_view.title.version" = "版本"; -"ehPanda_view.section.title.special_thanks" = "Special thanks"; -"ehPanda_view.section.title.code_level_contributors" = "Code-level contributors"; -"ehPanda_view.section.title.translation_contributors" = "Translation contributors"; -"ehPanda_view.section.title.acknowledgements" = "致謝"; +// MARK: AboutView +"about_view.title.ehPanda" = "EhPanda"; +"about_view.button.website" = "網站"; +"about_view.button.altStore_source" = "AltStore 源"; +"about_view.title.version" = "版本"; +"about_view.section.title.special_thanks" = "Special thanks"; +"about_view.section.title.code_level_contributors" = "Code-level contributors"; +"about_view.section.title.translation_contributors" = "Translation contributors"; +"about_view.section.title.acknowledgements" = "致謝"; // MARK: DetailView "detail_view.button.read" = "閱讀"; @@ -442,6 +443,8 @@ "eh_setting_view.section.title.front_page_settings" = "Front Page Settings"; "eh_setting_view.title.display_mode" = "Display mode"; "eh_setting_view.description.display_mode" = "Which display mode would you like to use on the front and search pages?"; +"eh_setting_view.section.title.show_search_range_indicator" = "Search Range Indicator"; +"eh_setting_view.title.show_search_range_indicator" = "Show search range indicator"; "eh_setting_view.description.gallery_category" = "What categories would you like to show by default on the front page and in searches?"; // EhSetting.DisplayMode "enum.eh_setting.display_mode.value.compact" = "Compact"; @@ -463,9 +466,6 @@ "eh_setting_view.promt.ratings_color" = "RRGGB"; "eh_setting_view.description.ratings_color" = "By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below. Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works."; -"eh_setting_view.section.title.tags_namespaces" = "Tag Namespaces"; -"eh_setting_view.description.tags_namespaces" = "If you want to exclude certain namespaces from a default tag search, you can check those below. Note that this does not prevent galleries with tags in these namespaces from appearing, it just makes it so that when searching tags, it will forego those namespaces."; - "eh_setting_view.section.title.tag_filtering_threshold" = "Tag Filtering Threshold"; "eh_setting_view.title.tag_filtering_threshold" = "Tag Filtering Threshold"; "eh_setting_view.description.tag_filtering_threshold" = "You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999."; diff --git a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings index d1b4746b..722e382e 100644 --- a/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant-TW.lproj/Localizable.strings @@ -139,8 +139,9 @@ "search_view.section.title.recently_searched" = "最近搜尋"; "search_view.section.title.recently_seen" = "最近閱讀"; "search_view.section.title.quick_search" = "快速搜尋"; -// Searchable prompt +// Searchable "searchable.prompt.filter" = "過濾"; +"searchable.title.matches_count" = "找到 %d 項結果"; // MARK: QuickSearchView "quick_search_view.title.quick_search" = "快速搜尋"; @@ -159,7 +160,7 @@ "enum.setting_state_route.value.appearance" = "外觀"; "enum.setting_state_route.value.reading" = "閱讀"; "enum.setting_state_route.value.laboratory" = "實驗性功能"; -"enum.setting_state_route.value.ehPanda" = "關於 EhPanda"; +"enum.setting_state_route.value.about" = "關於 EhPanda"; // MARK: AccountSettingView "account_setting_view.title.account" = "帳號設定"; @@ -254,15 +255,15 @@ "laboratory_setting_view.title.laboratory" = "實驗性功能"; "laboratory_setting_view.title.bypasses_SNI_filtering" = "繞過 SNI 過濾"; -// MARK: EhPandaView -"ehPanda_view.title.ehPanda" = "EhPanda"; -"ehPanda_view.button.website" = "官方網站"; -"ehPanda_view.button.altStore_source" = "AltStore source"; -"ehPanda_view.title.version" = "版本"; -"ehPanda_view.section.title.special_thanks" = "特別銘謝"; -"ehPanda_view.section.title.code_level_contributors" = "程式碼貢獻者"; -"ehPanda_view.section.title.translation_contributors" = "翻譯貢獻者"; -"ehPanda_view.section.title.acknowledgements" = "致謝"; +// MARK: AboutView +"about_view.title.ehPanda" = "EhPanda"; +"about_view.button.website" = "官方網站"; +"about_view.button.altStore_source" = "AltStore source"; +"about_view.title.version" = "版本"; +"about_view.section.title.special_thanks" = "特別銘謝"; +"about_view.section.title.code_level_contributors" = "程式碼貢獻者"; +"about_view.section.title.translation_contributors" = "翻譯貢獻者"; +"about_view.section.title.acknowledgements" = "致謝"; // MARK: DetailView "detail_view.button.read" = "閱讀"; @@ -442,6 +443,8 @@ "eh_setting_view.section.title.front_page_settings" = "首頁設定"; "eh_setting_view.title.display_mode" = "顯示模式"; "eh_setting_view.description.display_mode" = "你想在首頁和搜尋結果中使用哪一種顯示方式?"; +"eh_setting_view.section.title.show_search_range_indicator" = "搜尋範圍指示器"; +"eh_setting_view.title.show_search_range_indicator" = "顯示搜尋範圍指示器"; "eh_setting_view.description.gallery_category" = "預設情況下你希望在首頁和搜尋結果中顯示哪些類別的結果?"; // EhSetting.DisplayMode "enum.eh_setting.display_mode.value.compact" = "緊湊(Compact)"; @@ -463,9 +466,6 @@ "eh_setting_view.promt.ratings_color" = "RRGGB"; "eh_setting_view.description.ratings_color" = "預設情況下,你評分的畫廊將 2 星及以下的評分顯示為紅色星,2.5 ~ 4 顆星的評分為綠色,4.5 ~ 5 顆星的評分為藍色。 通過在下面輸入顏色組合你可以自訂想顯示的顏色。 每個字母各代表一顆星(1~5),預設的 RRGGB 表示第一顆和第二顆星的 R(ed),第三顆和第四顆的 G(reen),第五顆的 B(lue)。 你也可以將 (Y)ellow 用於普通星星。 任何五個字母的 R/G/B/Y 組合都有效"; -"eh_setting_view.section.title.tags_namespaces" = "標籤類型"; -"eh_setting_view.description.tags_namespaces" = "如果你想從標籤搜尋時排除從下列某些類型標籤出現在搜尋結果中,可以從下面更改設定。 注意!!這不會使搜尋結果中不出現那些畫廊,僅是限定搜尋標籤所在的類型範圍!"; - "eh_setting_view.section.title.tag_filtering_threshold" = "過濾標籤閾值"; "eh_setting_view.title.tag_filtering_threshold" = "閾值"; "eh_setting_view.description.tag_filtering_threshold" = "你可以通過將標籤新增到具有負數權重的“我的標籤”清單中來過濾標籤。 如果畫廊的標籤加起來的權重低於此值,則會被從列表中過濾掉,此閾值可以設定在 0 ~ -9999 之間"; diff --git a/EhPanda/App/zh-Hant.lproj/Localizable.strings b/EhPanda/App/zh-Hant.lproj/Localizable.strings index 2b650f26..e317ade9 100644 --- a/EhPanda/App/zh-Hant.lproj/Localizable.strings +++ b/EhPanda/App/zh-Hant.lproj/Localizable.strings @@ -139,8 +139,9 @@ "search_view.section.title.recently_searched" = "最近搜尋"; "search_view.section.title.recently_seen" = "最近閱讀"; "search_view.section.title.quick_search" = "快速搜尋"; -// Searchable prompt +// Searchable "searchable.prompt.filter" = "過濾"; +"searchable.title.matches_count" = "找到 %d 項結果"; // MARK: QuickSearchView "quick_search_view.title.quick_search" = "快速搜尋"; @@ -159,7 +160,7 @@ "enum.setting_state_route.value.appearance" = "外觀"; "enum.setting_state_route.value.reading" = "閱讀"; "enum.setting_state_route.value.laboratory" = "實驗性功能"; -"enum.setting_state_route.value.ehPanda" = "關於 EhPanda"; +"enum.setting_state_route.value.about" = "關於 EhPanda"; // MARK: AccountSettingView "account_setting_view.title.account" = "帳號設定"; @@ -254,15 +255,15 @@ "laboratory_setting_view.title.laboratory" = "實驗性功能"; "laboratory_setting_view.title.bypasses_SNI_filtering" = "繞過 SNI 過濾"; -// MARK: EhPandaView -"ehPanda_view.title.ehPanda" = "EhPanda"; -"ehPanda_view.button.website" = "官方網站"; -"ehPanda_view.button.altStore_source" = "AltStore source"; -"ehPanda_view.title.version" = "版本"; -"ehPanda_view.section.title.special_thanks" = "特別銘謝"; -"ehPanda_view.section.title.code_level_contributors" = "程式碼貢獻者"; -"ehPanda_view.section.title.translation_contributors" = "翻譯貢獻者"; -"ehPanda_view.section.title.acknowledgements" = "致謝"; +// MARK: AboutView +"about_view.title.ehPanda" = "EhPanda"; +"about_view.button.website" = "官方網站"; +"about_view.button.altStore_source" = "AltStore source"; +"about_view.title.version" = "版本"; +"about_view.section.title.special_thanks" = "特別銘謝"; +"about_view.section.title.code_level_contributors" = "程式碼貢獻者"; +"about_view.section.title.translation_contributors" = "翻譯貢獻者"; +"about_view.section.title.acknowledgements" = "致謝"; // MARK: DetailView "detail_view.button.read" = "閱讀"; @@ -442,6 +443,8 @@ "eh_setting_view.section.title.front_page_settings" = "首頁設定"; "eh_setting_view.title.display_mode" = "顯示模式"; "eh_setting_view.description.display_mode" = "你想在首頁和搜尋結果中使用哪一種顯示方式?"; +"eh_setting_view.section.title.show_search_range_indicator" = "搜尋範圍指示器"; +"eh_setting_view.title.show_search_range_indicator" = "顯示搜尋範圍指示器"; "eh_setting_view.description.gallery_category" = "預設情況下你希望在首頁和搜尋結果中顯示哪些類別的結果?"; // EhSetting.DisplayMode "enum.eh_setting.display_mode.value.compact" = "緊湊(Compact)"; @@ -463,9 +466,6 @@ "eh_setting_view.promt.ratings_color" = "RRGGB"; "eh_setting_view.description.ratings_color" = "預設情況下,你評分的畫廊將 2 星及以下的評分顯示為紅色星,2.5 ~ 4 顆星的評分為綠色,4.5 ~ 5 顆星的評分為藍色。 通過在下面輸入顏色組合你可以自訂想顯示的顏色。 每個字母各代表一顆星(1~5),預設的 RRGGB 表示第一顆和第二顆星的 R(ed),第三顆和第四顆的 G(reen),第五顆的 B(lue)。 你也可以將 (Y)ellow 用於普通星星。 任何五個字母的 R/G/B/Y 組合都有效"; -"eh_setting_view.section.title.tags_namespaces" = "標籤類型"; -"eh_setting_view.description.tags_namespaces" = "如果你想從標籤搜尋時排除從下列某些類型標籤出現在搜尋結果中,可以從下面更改設定。 注意!!這不會使搜尋結果中不出現那些畫廊,僅是限定搜尋標籤所在的類型範圍!"; - "eh_setting_view.section.title.tag_filtering_threshold" = "過濾標籤閾值"; "eh_setting_view.title.tag_filtering_threshold" = "閾值"; "eh_setting_view.description.tag_filtering_threshold" = "你可以通過將標籤新增到具有負數權重的“我的標籤”清單中來過濾標籤。 如果畫廊的標籤加起來的權重低於此值,則會被從列表中過濾掉,此閾值可以設定在 0 ~ -9999 之間"; diff --git a/EhPanda/DataFlow/AppDelegateReducer.swift b/EhPanda/DataFlow/AppDelegateReducer.swift new file mode 100644 index 00000000..53efa716 --- /dev/null +++ b/EhPanda/DataFlow/AppDelegateReducer.swift @@ -0,0 +1,77 @@ +// +// AppDelegateReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/25. +// + +import SwiftUI +import SwiftyBeaver +import ComposableArchitecture + +struct AppDelegateReducer: ReducerProtocol { + struct State: Equatable { + var migrationState = MigrationReducer.State() + } + + enum Action: Equatable { + case onLaunchFinish + case removeExpiredImageURLs + + case migration(MigrationReducer.Action) + } + + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.libraryClient) private var libraryClient + @Dependency(\.cookieClient) private var cookieClient + + var body: some ReducerProtocol { + Reduce { _, action in + switch action { + case .onLaunchFinish: + return .merge( + libraryClient.initializeLogger().fireAndForget(), + libraryClient.initializeWebImage().fireAndForget(), + cookieClient.removeYay().fireAndForget(), + cookieClient.syncExCookies().fireAndForget(), + cookieClient.ignoreOffensive().fireAndForget(), + cookieClient.fulfillAnotherHostField().fireAndForget(), + .init(value: .migration(.prepareDatabase)) + ) + + case .removeExpiredImageURLs: + return databaseClient.removeExpiredImageURLs().fireAndForget() + + case .migration: + return .none + } + } + + Scope(state: \.migrationState, action: /Action.migration, child: MigrationReducer.init) + } +} + +// MARK: AppDelegate +class AppDelegate: UIResponder, UIApplicationDelegate { + let store = Store( + initialState: .init(), + reducer: AppReducer() + ) + lazy var viewStore = ViewStore(store) + + static var orientationMask: UIInterfaceOrientationMask = DeviceUtil.isPad ? .all : [.portrait, .portraitUpsideDown] + + func application( + _ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow? + ) -> UIInterfaceOrientationMask { AppDelegate.orientationMask } + + func application( + _ application: UIApplication, didFinishLaunchingWithOptions + launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + if !AppUtil.isTesting { + viewStore.send(.appDelegate(.onLaunchFinish)) + } + return true + } +} diff --git a/EhPanda/DataFlow/AppDelegateStore.swift b/EhPanda/DataFlow/AppDelegateStore.swift deleted file mode 100644 index d0f4e2bd..00000000 --- a/EhPanda/DataFlow/AppDelegateStore.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// AppDelegateStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/12/25. -// - -import SwiftUI -import SwiftyBeaver -import ComposableArchitecture - -// MARK: AppDelegate -class AppDelegate: UIResponder, UIApplicationDelegate { - let store = Store( - initialState: AppState(), - reducer: appReducer, - environment: AppEnvironment( - dfClient: .live, - urlClient: .live, - fileClient: .live, - imageClient: .live, - deviceClient: .live, - loggerClient: .live, - hapticClient: .live, - libraryClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live, - userDefaultsClient: .live, - uiApplicationClient: .live, - authorizationClient: .live - ) - ) - lazy var viewStore = ViewStore(store) - - static var orientationMask: UIInterfaceOrientationMask = - DeviceUtil.isPad ? .all : [.portrait, .portraitUpsideDown] - - func application( - _ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow? - ) -> UIInterfaceOrientationMask { AppDelegate.orientationMask } - - func application( - _ application: UIApplication, didFinishLaunchingWithOptions - launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool { - viewStore.send(.appDelegate(.onLaunchFinish)) - return true - } -} - -struct AppDelegateState: Equatable { - var migrationState = MigrationState() -} - -enum AppDelegateAction { - case onLaunchFinish - case removeExpiredImageURLs - - case migration(MigrationAction) -} - -struct AppDelegateEnvironment { - let dfClient: DFClient - let libraryClient: LibraryClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient -} - -let appDelegateReducer = Reducer.combine( - .init { _, action, environment in - switch action { - case .onLaunchFinish: - return .merge( - environment.libraryClient.initializeLogger().fireAndForget(), - environment.libraryClient.initializeWebImage().fireAndForget(), - environment.cookiesClient.removeYay().fireAndForget(), - environment.cookiesClient.ignoreOffensive().fireAndForget(), - environment.cookiesClient.fulfillAnotherHostField().fireAndForget(), - .init(value: .migration(.prepareDatabase)) - ) - - case .removeExpiredImageURLs: - return environment.databaseClient.removeExpiredImageURLs().fireAndForget() - - case .migration: - return .none - } - }, - migrationReducer.pullback( - state: \.migrationState, - action: /AppDelegateAction.migration, - environment: { - .init( - databaseClient: $0.databaseClient - ) - } - ) -) diff --git a/EhPanda/DataFlow/AppLockReducer.swift b/EhPanda/DataFlow/AppLockReducer.swift new file mode 100644 index 00000000..8c2f5d76 --- /dev/null +++ b/EhPanda/DataFlow/AppLockReducer.swift @@ -0,0 +1,74 @@ +// +// AppLockReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/05. +// + +import SwiftUI +import ComposableArchitecture + +struct AppLockReducer: ReducerProtocol { + struct State: Equatable { + @BindingState var blurRadius: Double = 0 + var becameInactiveDate: Date? + var isAppLocked = false + + // Setting `blurRadius` to zero causes the NavigationBar to collapse + mutating func setBlurRadius(_ radius: Double) { + blurRadius = max(0.00001, radius) + } + } + + enum Action: Equatable { + case onBecomeActive(Int, Double) + case onBecomeInactive(Double) + case lockApp(Double) + case unlockApp + case authorize + case authorizeDone(Bool) + } + + @Dependency(\.authorizationClient) private var authorizationClient + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .onBecomeActive(let threshold, let blurRadius): + if let date = state.becameInactiveDate, threshold >= 0, + Date().timeIntervalSince(date) > Double(threshold) + { + return .merge( + .init(value: .authorize), + .init(value: .lockApp(blurRadius)) + ) + } else { + return .init(value: .unlockApp) + } + + case .onBecomeInactive(let blurRadius): + state.setBlurRadius(blurRadius) + state.becameInactiveDate = .now + return .none + + case .lockApp(let blurRadius): + state.setBlurRadius(blurRadius) + state.isAppLocked = true + return .none + + case .unlockApp: + state.setBlurRadius(0) + state.isAppLocked = false + state.becameInactiveDate = nil + return .none + + case .authorize: + return authorizationClient.localAuthroize(L10n.Localizable.LocalAuthorization.reason) + .map(Action.authorizeDone) + + case .authorizeDone(let isSucceeded): + return isSucceeded ? .init(value: .unlockApp) : .none + } + } + } +} diff --git a/EhPanda/DataFlow/AppLockStore.swift b/EhPanda/DataFlow/AppLockStore.swift deleted file mode 100644 index 1743bea5..00000000 --- a/EhPanda/DataFlow/AppLockStore.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// AppLockStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/05. -// - -import SwiftUI -import ComposableArchitecture - -struct AppLockState: Equatable { - @BindableState var blurRadius: Double = 0 - var becameInactiveDate: Date? - var isAppLocked = false - - // Setting `blurRadius` to zero causes the NavigationBar to collapse - mutating func setBlurRadius(_ radius: Double) { - blurRadius = max(0.00001, radius) - } -} - -enum AppLockAction { - case onBecomeActive(Int, Double) - case onBecomeInactive(Double) - case lockApp(Double) - case unlockApp - case authorize - case authorizeDone(Bool) -} - -struct AppLockEnvironment { - let authorizationClient: AuthorizationClient -} - -let appLockReducer = Reducer { state, action, environment in - switch action { - case .onBecomeActive(let threshold, let blurRadius): - if let date = state.becameInactiveDate, threshold >= 0, - Date().timeIntervalSince(date) > Double(threshold) - { - return .merge( - .init(value: .authorize), - .init(value: .lockApp(blurRadius)) - ) - } else { - return .init(value: .unlockApp) - } - - case .onBecomeInactive(let blurRadius): - state.setBlurRadius(blurRadius) - state.becameInactiveDate = .now - return .none - - case .lockApp(let blurRadius): - state.setBlurRadius(blurRadius) - state.isAppLocked = true - return .none - - case .unlockApp: - state.setBlurRadius(0) - state.isAppLocked = false - state.becameInactiveDate = nil - return .none - - case .authorize: - return environment.authorizationClient - .localAuthroize(L10n.Localizable.LocalAuthorization.reason) - .map(AppLockAction.authorizeDone) - - case .authorizeDone(let isSucceeded): - return isSucceeded ? .init(value: .unlockApp) : .none - } -} diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift new file mode 100644 index 00000000..684c0121 --- /dev/null +++ b/EhPanda/DataFlow/AppReducer.swift @@ -0,0 +1,205 @@ +// +// AppReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/25. +// + +import SwiftUI +import ComposableArchitecture + +struct AppReducer: ReducerProtocol { + struct State: Equatable { + var appDelegateState = AppDelegateReducer.State() + var appRouteState = AppRouteReducer.State() + var appLockState = AppLockReducer.State() + var tabBarState = TabBarReducer.State() + var homeState = HomeReducer.State() + var favoritesState = FavoritesReducer.State() + var searchRootState = SearchRootReducer.State() + var settingState = SettingReducer.State() + } + + enum Action: BindableAction { + case binding(BindingAction) + case onScenePhaseChange(ScenePhase) + + case appDelegate(AppDelegateReducer.Action) + case appRoute(AppRouteReducer.Action) + case appLock(AppLockReducer.Action) + + case tabBar(TabBarReducer.Action) + + case home(HomeReducer.Action) + case favorites(FavoritesReducer.Action) + case searchRoot(SearchRootReducer.Action) + case setting(SettingReducer.Action) + } + + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.cookieClient) private var cookieClient + @Dependency(\.deviceClient) private var deviceClient + + var body: some ReducerProtocol { + LoggingReducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.appRouteState.$route): + return state.appRouteState.route == nil ? .init(value: .appRoute(.clearSubStates)) : .none + + case .binding(\.settingState.$setting): + return .init(value: .setting(.syncSetting)) + + case .binding: + return .none + + case .onScenePhaseChange(let scenePhase): + switch scenePhase { + case .active: + let threshold = state.settingState.setting.autoLockPolicy.rawValue + let blurRadius = state.settingState.setting.backgroundBlurRadius + return .init(value: .appLock(.onBecomeActive(threshold, blurRadius))) + case .inactive: + let blurRadius = state.settingState.setting.backgroundBlurRadius + return .init(value: .appLock(.onBecomeInactive(blurRadius))) + default: + break + } + return .none + + case .appDelegate(.migration(.onDatabasePreparationSuccess)): + return .merge( + .init(value: .appDelegate(.removeExpiredImageURLs)), + .init(value: .setting(.loadUserSettings)) + ) + + case .appDelegate: + return .none + + case .appRoute(.clearSubStates): + var effects = [EffectTask]() + if deviceClient.isPad() { + state.settingState.route = nil + effects.append(.init(value: .setting(.clearSubStates))) + } + return effects.isEmpty ? .none : .merge(effects) + + case .appRoute: + return .none + + case .appLock(.unlockApp): + var effects: [EffectTask] = [ + .init(value: .setting(.fetchGreeting)) + ] + if state.settingState.setting.detectsLinksFromClipboard { + effects.append(.init(value: .appRoute(.detectClipboardURL))) + } + return .merge(effects) + + case .appLock: + return .none + + case .tabBar(.setTabBarItemType(let type)): + var effects = [EffectTask]() + let hapticEffect: EffectTask = .fireAndForget({ hapticsClient.generateFeedback(.soft) }) + if type == state.tabBarState.tabBarItemType { + switch type { + case .home: + if state.homeState.route != nil { + effects.append(.init(value: .home(.setNavigation(nil)))) + } else { + effects.append(.init(value: .home(.fetchAllGalleries))) + } + case .favorites: + if state.favoritesState.route != nil { + effects.append(.init(value: .favorites(.setNavigation(nil)))) + effects.append(hapticEffect) + } else if cookieClient.didLogin { + effects.append(.init(value: .favorites(.fetchGalleries()))) + effects.append(hapticEffect) + } + case .search: + if state.searchRootState.route != nil { + effects.append(.init(value: .searchRoot(.setNavigation(nil)))) + } else { + effects.append(.init(value: .searchRoot(.fetchDatabaseInfos))) + } + case .setting: + if state.settingState.route != nil { + effects.append(.init(value: .setting(.setNavigation(nil)))) + effects.append(hapticEffect) + } + } + if [.home, .search].contains(type) { + effects.append(hapticEffect) + } + } + if type == .setting && deviceClient.isPad() { + effects.append(.init(value: .appRoute(.setNavigation(.setting)))) + } + return effects.isEmpty ? .none : .merge(effects) + + case .tabBar: + return .none + + case .home(.watched(.onNotLoginViewButtonTapped)), .favorites(.onNotLoginViewButtonTapped): + var effects: [EffectTask] = [ + .fireAndForget({ hapticsClient.generateFeedback(.soft) }), + .init(value: .tabBar(.setTabBarItemType(.setting))) + ] + effects.append(.init(value: .setting(.setNavigation(.account)))) + if !cookieClient.didLogin { + effects.append( + .init(value: .setting(.account(.setNavigation(.login)))) + .delay( + for: .milliseconds(deviceClient.isPad() ? 1200 : 200), + scheduler: DispatchQueue.main + ) + .eraseToEffect() + ) + } + return .merge(effects) + + case .home: + return .none + + case .favorites: + return .none + + case .searchRoot: + return .none + + case .setting(.loadUserSettingsDone): + var effects = [EffectTask]() + let threshold = state.settingState.setting.autoLockPolicy.rawValue + let blurRadius = state.settingState.setting.backgroundBlurRadius + if threshold >= 0 { + state.appLockState.becameInactiveDate = .distantPast + effects.append(.init(value: .appLock(.onBecomeActive(threshold, blurRadius)))) + } + if state.settingState.setting.detectsLinksFromClipboard { + effects.append(.init(value: .appRoute(.detectClipboardURL))) + } + return effects.isEmpty ? .none : .merge(effects) + + case .setting(.fetchGreetingDone(let result)): + return .init(value: .appRoute(.fetchGreetingDone(result))) + + case .setting: + return .none + } + } + + Scope(state: \.appRouteState, action: /Action.appRoute, child: AppRouteReducer.init) + Scope(state: \.appLockState, action: /Action.appLock, child: AppLockReducer.init) + Scope(state: \.appDelegateState, action: /Action.appDelegate, child: AppDelegateReducer.init) + Scope(state: \.tabBarState, action: /Action.tabBar, child: TabBarReducer.init) + Scope(state: \.homeState, action: /Action.home, child: HomeReducer.init) + Scope(state: \.favoritesState, action: /Action.favorites, child: FavoritesReducer.init) + Scope(state: \.searchRootState, action: /Action.searchRoot, child: SearchRootReducer.init) + Scope(state: \.settingState, action: /Action.setting, child: SettingReducer.init) + } + } +} diff --git a/EhPanda/DataFlow/AppRouteReducer.swift b/EhPanda/DataFlow/AppRouteReducer.swift new file mode 100644 index 00000000..9b410b04 --- /dev/null +++ b/EhPanda/DataFlow/AppRouteReducer.swift @@ -0,0 +1,178 @@ +// +// AppRouteReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/08. +// + +import SwiftUI +import TTProgressHUD +import ComposableArchitecture + +struct AppRouteReducer: ReducerProtocol { + enum Route: Equatable, Hashable { + case hud + case setting + case detail(String) + case newDawn(Greeting) + } + + struct State: Equatable { + @BindingState var route: Route? + var hudConfig: TTProgressHUDConfig = .loading + + @Heap var detailState: DetailReducer.State! + + init() { + _detailState = .init(.init()) + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case setHUDConfig(TTProgressHUDConfig) + case clearSubStates + + case detectClipboardURL + case handleDeepLink(URL) + case handleGalleryLink(URL) + + case updateReadingProgress(String, Int) + + case fetchGallery(URL, Bool) + case fetchGalleryDone(URL, Result) + case fetchGreetingDone(Result) + + case detail(DetailReducer.Action) + } + + @Dependency(\.userDefaultsClient) private var userDefaultsClient + @Dependency(\.clipboardClient) private var clipboardClient + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.urlClient) private var urlClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .setHUDConfig(let config): + state.hudConfig = config + return .none + + case .clearSubStates: + state.detailState = .init() + return .init(value: .detail(.teardown)) + + case .detectClipboardURL: + let currentChangeCount = clipboardClient.changeCount() + guard currentChangeCount != userDefaultsClient + .getValue(.clipboardChangeCount) else { return .none } + var effects: [EffectTask] = [ + userDefaultsClient + .setValue(currentChangeCount, .clipboardChangeCount).fireAndForget() + ] + if let url = clipboardClient.url() { + effects.append(.init(value: .handleDeepLink(url))) + } + return .merge(effects) + + case .handleDeepLink(let url): + let url = urlClient.resolveAppSchemeURL(url) ?? url + guard urlClient.checkIfHandleable(url) else { return .none } + var delay = 0 + if case .detail = state.route { + delay = 1000 + state.route = nil + state.detailState = .init() + } + let (isGalleryImageURL, _, _) = urlClient.analyzeURL(url) + let gid = urlClient.parseGalleryID(url) + guard databaseClient.fetchGallery(gid: gid) == nil else { + return .init(value: .handleGalleryLink(url)) + .delay(for: .milliseconds(delay + 250), scheduler: DispatchQueue.main).eraseToEffect() + } + return .init(value: .fetchGallery(url, isGalleryImageURL)) + .delay(for: .milliseconds(delay), scheduler: DispatchQueue.main).eraseToEffect() + + case .handleGalleryLink(let url): + let (_, pageIndex, commentID) = urlClient.analyzeURL(url) + let gid = urlClient.parseGalleryID(url) + var effects = [EffectTask]() + state.detailState = .init() + effects.append(.init(value: .detail(.fetchDatabaseInfos(gid)))) + if let pageIndex = pageIndex { + effects.append(.init(value: .updateReadingProgress(gid, pageIndex))) + effects.append( + .init(value: .detail(.setNavigation(.reading))) + .delay(for: .milliseconds(500), scheduler: DispatchQueue.main).eraseToEffect() + ) + } else if let commentID = commentID { + state.detailState.commentsState?.scrollCommentID = commentID + effects.append( + .init(value: .detail(.setNavigation(.comments(url)))) + .delay(for: .milliseconds(500), scheduler: DispatchQueue.main).eraseToEffect() + ) + } + effects.append(.init(value: .setNavigation(.detail(gid)))) + return .merge(effects) + + case .updateReadingProgress(let gid, let progress): + guard !gid.isEmpty else { return .none } + return databaseClient + .updateReadingProgress(gid: gid, progress: progress).fireAndForget() + + case .fetchGallery(let url, let isGalleryImageURL): + state.route = .hud + return GalleryReverseRequest(url: url, isGalleryImageURL: isGalleryImageURL) + .effect.map({ Action.fetchGalleryDone(url, $0) }) + + case .fetchGalleryDone(let url, let result): + state.route = nil + switch result { + case .success(let gallery): + return .merge( + databaseClient.cacheGalleries([gallery]).fireAndForget(), + .init(value: .handleGalleryLink(url)) + ) + case .failure: + return .init(value: .setHUDConfig(.error)) + .delay(for: .milliseconds(500), scheduler: DispatchQueue.main).eraseToEffect() + } + + case .fetchGreetingDone(let result): + if case .success(let greeting) = result, !greeting.gainedNothing { + return .init(value: .setNavigation(.newDawn(greeting))) + } + return .none + + case .detail: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /Route.newDawn, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.route, + case: /Route.detail, + hapticsClient: hapticsClient + ) + + Scope(state: \.detailState, action: /Action.detail, child: DetailReducer.init) + } +} diff --git a/EhPanda/DataFlow/AppRouteStore.swift b/EhPanda/DataFlow/AppRouteStore.swift deleted file mode 100644 index c5e1acb8..00000000 --- a/EhPanda/DataFlow/AppRouteStore.swift +++ /dev/null @@ -1,203 +0,0 @@ -// -// AppRouteStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/08. -// - -import SwiftUI -import TTProgressHUD -import ComposableArchitecture - -struct AppRouteState: Equatable { - enum Route: Equatable, Hashable { - case hud - case setting - case detail(String) - case newDawn(Greeting) - } - - init() { - _detailState = .init(.init()) - } - - @BindableState var route: Route? - var hudConfig: TTProgressHUDConfig = .loading - - @Heap var detailState: DetailState! -} - -enum AppRouteAction: BindableAction { - case binding(BindingAction) - case setNavigation(AppRouteState.Route?) - case setHUDConfig(TTProgressHUDConfig) - case clearSubStates - - case detectClipboardURL - case handleDeepLink(URL) - case handleGalleryLink(URL) - - case updateReadingProgress(String, Int) - - case fetchGallery(URL, Bool) - case fetchGalleryDone(URL, Result) - case fetchGreetingDone(Result) - - case detail(DetailAction) -} - -struct AppRouteEnvironment { - let dfClient: DFClient - let urlClient: URLClient - let fileClient: FileClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let loggerClient: LoggerClient - let hapticClient: HapticClient - let libraryClient: LibraryClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient - let userDefaultsClient: UserDefaultsClient - let uiApplicationClient: UIApplicationClient - let authorizationClient: AuthorizationClient -} - -let appRouteReducer = Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .setHUDConfig(let config): - state.hudConfig = config - return .none - - case .clearSubStates: - state.detailState = .init() - return .init(value: .detail(.teardown)) - - case .detectClipboardURL: - let currentChangeCount = environment.clipboardClient.changeCount() - guard currentChangeCount != environment.userDefaultsClient - .getValue(.clipboardChangeCount) else { return .none } - var effects: [Effect] = [ - environment.userDefaultsClient - .setValue(currentChangeCount, .clipboardChangeCount).fireAndForget() - ] - if let url = environment.clipboardClient.url() { - effects.append(.init(value: .handleDeepLink(url))) - } - return .merge(effects) - - case .handleDeepLink(let url): - var url = environment.urlClient.resolveAppSchemeURL(url) ?? url - guard environment.urlClient.checkIfHandleable(url) else { return .none } - var delay = 0 - if case .detail = state.route { - delay = 1000 - state.route = nil - state.detailState = .init() - } - let (isGalleryImageURL, _, _) = environment.urlClient.analyzeURL(url) - let gid = environment.urlClient.parseGalleryID(url) - guard environment.databaseClient.fetchGallery(gid: gid) == nil else { - return .init(value: .handleGalleryLink(url)) - .delay(for: .milliseconds(delay + 250), scheduler: DispatchQueue.main).eraseToEffect() - } - return .init(value: .fetchGallery(url, isGalleryImageURL)) - .delay(for: .milliseconds(delay), scheduler: DispatchQueue.main).eraseToEffect() - - case .handleGalleryLink(let url): - let (_, pageIndex, commentID) = environment.urlClient.analyzeURL(url) - let gid = environment.urlClient.parseGalleryID(url) - var effects = [Effect]() - state.detailState = .init() - effects.append(.init(value: .detail(.fetchDatabaseInfos(gid)))) - if let pageIndex = pageIndex { - effects.append(.init(value: .updateReadingProgress(gid, pageIndex))) - effects.append( - .init(value: .detail(.setNavigation(.reading))) - .delay(for: .milliseconds(500), scheduler: DispatchQueue.main).eraseToEffect() - ) - } else if let commentID = commentID { - state.detailState.commentsState?.scrollCommentID = commentID - effects.append( - .init(value: .detail(.setNavigation(.comments(url)))) - .delay(for: .milliseconds(500), scheduler: DispatchQueue.main).eraseToEffect() - ) - } - effects.append(.init(value: .setNavigation(.detail(gid)))) - return .merge(effects) - - case .updateReadingProgress(let gid, let progress): - guard !gid.isEmpty else { return .none } - return environment.databaseClient - .updateReadingProgress(gid: gid, progress: progress).fireAndForget() - - case .fetchGallery(let url, let isGalleryImageURL): - state.route = .hud - return GalleryReverseRequest(url: url, isGalleryImageURL: isGalleryImageURL) - .effect.map({ AppRouteAction.fetchGalleryDone(url, $0) }) - - case .fetchGalleryDone(let url, let result): - state.route = nil - switch result { - case .success(let gallery): - return .merge( - environment.databaseClient.cacheGalleries([gallery]).fireAndForget(), - .init(value: .handleGalleryLink(url)) - ) - case .failure: - return .init(value: .setHUDConfig(.error)) - .delay(for: .milliseconds(500), scheduler: DispatchQueue.main).eraseToEffect() - } - - case .fetchGreetingDone(let result): - if case .success(let greeting) = result, !greeting.gainedNothing { - return .init(value: .setNavigation(.newDawn(greeting))) - } - return .none - - case .detail: - return .none - } - } - .haptics( - unwrapping: \.route, - case: /AppRouteState.Route.newDawn, - hapticClient: \.hapticClient - ) - .haptics( - unwrapping: \.route, - case: /AppRouteState.Route.detail, - hapticClient: \.hapticClient - ) - .binding(), - detailReducer.pullback( - state: \.detailState, - action: /AppRouteAction.detail, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ) -) diff --git a/EhPanda/DataFlow/AppStore.swift b/EhPanda/DataFlow/AppStore.swift deleted file mode 100644 index 70661104..00000000 --- a/EhPanda/DataFlow/AppStore.swift +++ /dev/null @@ -1,340 +0,0 @@ -// -// AppStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/12/25. -// - -import SwiftUI -import ComposableArchitecture - -struct AppState: Equatable { - var appDelegateState = AppDelegateState() - var appRouteState = AppRouteState() - var appLockState = AppLockState() - var tabBarState = TabBarState() - var homeState = HomeState() - var favoritesState = FavoritesState() - var searchRootState = SearchRootState() - var settingState = SettingState() -} - -enum AppAction: BindableAction { - case binding(BindingAction) - case onScenePhaseChange(ScenePhase) - - case appDelegate(AppDelegateAction) - case appRoute(AppRouteAction) - case appLock(AppLockAction) - - case tabBar(TabBarAction) - - case home(HomeAction) - case favorites(FavoritesAction) - case searchRoot(SearchRootAction) - case setting(SettingAction) -} - -struct AnyEnvironment {} -struct AppEnvironment { - let dfClient: DFClient - let urlClient: URLClient - let fileClient: FileClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let loggerClient: LoggerClient - let hapticClient: HapticClient - let libraryClient: LibraryClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient - let userDefaultsClient: UserDefaultsClient - let uiApplicationClient: UIApplicationClient - let authorizationClient: AuthorizationClient -} - -let appReducerCore = Reducer { state, action, environment in - switch action { - case .binding(\.appRouteState.$route): - return state.appRouteState.route == nil ? .init(value: .appRoute(.clearSubStates)) : .none - - case .binding(\.settingState.$setting): - return .init(value: .setting(.syncSetting)) - - case .binding: - return .none - - case .onScenePhaseChange(let scenePhase): - switch scenePhase { - case .active: - let threshold = state.settingState.setting.autoLockPolicy.rawValue - let blurRadius = state.settingState.setting.backgroundBlurRadius - return .init(value: .appLock(.onBecomeActive(threshold, blurRadius))) - case .inactive: - let blurRadius = state.settingState.setting.backgroundBlurRadius - return .init(value: .appLock(.onBecomeInactive(blurRadius))) - default: - break - } - return .none - - case .appDelegate(.migration(.onDatabasePreparationSuccess)): - return .merge( - .init(value: .appDelegate(.removeExpiredImageURLs)), - .init(value: .setting(.loadUserSettings)) - ) - - case .appDelegate: - return .none - - case .appRoute(.clearSubStates): - var effects = [Effect]() - if environment.deviceClient.isPad() { - state.settingState.route = nil - effects.append(.init(value: .setting(.clearSubStates))) - } - return effects.isEmpty ? .none : .merge(effects) - - case .appRoute: - return .none - - case .appLock(.unlockApp): - var effects: [Effect] = [ - .init(value: .setting(.fetchGreeting)) - ] - if state.settingState.setting.detectsLinksFromClipboard { - effects.append(.init(value: .appRoute(.detectClipboardURL))) - } - return .merge(effects) - - case .appLock: - return .none - - case .tabBar(.setTabBarItemType(let type)): - var effects = [Effect]() - let hapticEffect: Effect = environment.hapticClient - .generateFeedback(.soft).fireAndForget() - if type == state.tabBarState.tabBarItemType { - switch type { - case .home: - if state.homeState.route != nil { - effects.append(.init(value: .home(.setNavigation(nil)))) - } else { - effects.append(.init(value: .home(.fetchAllGalleries))) - } - case .favorites: - if state.favoritesState.route != nil { - effects.append(.init(value: .favorites(.setNavigation(nil)))) - effects.append(hapticEffect) - } else if environment.cookiesClient.didLogin { - effects.append(.init(value: .favorites(.fetchGalleries()))) - effects.append(hapticEffect) - } - case .search: - if state.searchRootState.route != nil { - effects.append(.init(value: .searchRoot(.setNavigation(nil)))) - } else { - effects.append(.init(value: .searchRoot(.fetchDatabaseInfos))) - } - case .setting: - if state.settingState.route != nil { - effects.append(.init(value: .setting(.setNavigation(nil)))) - effects.append(hapticEffect) - } - } - if [.home, .search].contains(type) { - effects.append(hapticEffect) - } - } - if type == .setting && environment.deviceClient.isPad() { - effects.append(.init(value: .appRoute(.setNavigation(.setting)))) - } - return effects.isEmpty ? .none : .merge(effects) - - case .tabBar: - return .none - - case .home(.watched(.onNotLoginViewButtonTapped)), .favorites(.onNotLoginViewButtonTapped): - var effects: [Effect] = [ - environment.hapticClient.generateFeedback(.soft).fireAndForget(), - .init(value: .tabBar(.setTabBarItemType(.setting))) - ] - effects.append(.init(value: .setting(.setNavigation(.account)))) - if !environment.cookiesClient.didLogin { - effects.append( - .init(value: .setting(.account(.setNavigation(.login)))) - .delay( - for: .milliseconds(environment.deviceClient.isPad() ? 1200 : 200), - scheduler: DispatchQueue.main - ) - .eraseToEffect() - ) - } - return .merge(effects) - - case .home: - return .none - - case .favorites: - return .none - - case .searchRoot: - return .none - - case .setting(.loadUserSettingsDone): - var effects = [Effect]() - let threshold = state.settingState.setting.autoLockPolicy.rawValue - let blurRadius = state.settingState.setting.backgroundBlurRadius - if threshold >= 0 { - state.appLockState.becameInactiveDate = .distantPast - effects.append(.init(value: .appLock(.onBecomeActive(threshold, blurRadius)))) - } - if state.settingState.setting.detectsLinksFromClipboard { - effects.append(.init(value: .appRoute(.detectClipboardURL))) - } - return effects.isEmpty ? .none : .merge(effects) - - case .setting(.fetchGreetingDone(let result)): - return .init(value: .appRoute(.fetchGreetingDone(result))) - - case .setting: - return .none - } -} -.binding() - -let appReducer = Reducer.combine( - appReducerCore, - appRouteReducer.pullback( - state: \.appRouteState, - action: /AppAction.appRoute, - environment: { - .init( - dfClient: $0.dfClient, - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - loggerClient: $0.loggerClient, - hapticClient: $0.hapticClient, - libraryClient: $0.libraryClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - userDefaultsClient: $0.userDefaultsClient, - uiApplicationClient: $0.uiApplicationClient, - authorizationClient: $0.authorizationClient - ) - } - ), - appLockReducer.pullback( - state: \.appLockState, - action: /AppAction.appLock, - environment: { - .init( - authorizationClient: $0.authorizationClient - ) - } - ), - appDelegateReducer.pullback( - state: \.appDelegateState, - action: /AppAction.appDelegate, - environment: { - .init( - dfClient: $0.dfClient, - libraryClient: $0.libraryClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient - ) - } - ), - tabBarReducer.pullback( - state: \.tabBarState, - action: /AppAction.tabBar, - environment: { - .init( - deviceClient: $0.deviceClient - ) - } - ), - homeReducer.pullback( - state: \.homeState, - action: /AppAction.home, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - libraryClient: $0.libraryClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ), - favoritesReducer.pullback( - state: \.favoritesState, - action: /AppAction.favorites, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - userDefaultsClient: $0.userDefaultsClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ), - searchRootReducer.pullback( - state: \.searchRootState, - action: /AppAction.searchRoot, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ), - settingReducer.pullback( - state: \.settingState, - action: /AppAction.setting, - environment: { - .init( - dfClient: $0.dfClient, - fileClient: $0.fileClient, - deviceClient: $0.deviceClient, - loggerClient: $0.loggerClient, - hapticClient: $0.hapticClient, - libraryClient: $0.libraryClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - userDefaultsClient: $0.userDefaultsClient, - uiApplicationClient: $0.uiApplicationClient, - authorizationClient: $0.authorizationClient - ) - } - ) -) -.logging() diff --git a/EhPanda/Models/Gallery/GalleryState.swift b/EhPanda/Models/Gallery/GalleryState.swift index 9ca74759..f151b670 100644 --- a/EhPanda/Models/Gallery/GalleryState.swift +++ b/EhPanda/Models/Gallery/GalleryState.swift @@ -24,17 +24,20 @@ struct GalleryState: Codable { } extension GalleryState: CustomStringConvertible { var description: String { - let params = String(describing: [ - "gid": gid, - "tagsCount": tags.count, - "readingProgress": readingProgress, - "previewURLsCount": previewURLs.count, - "previewConfig": String(describing: previewConfig), - "commentsCount": comments.count, - "imageURLsCount": imageURLs.count, - "originalImageURLsCount": originalImageURLs.count, - "thumbnailURLsCount": thumbnailURLs.count - ]) + let params = String( + describing: [ + "gid": gid, + "tagsCount": tags.count, + "readingProgress": readingProgress, + "previewURLsCount": previewURLs.count, + "previewConfig": String(describing: previewConfig), + "commentsCount": comments.count, + "imageURLsCount": imageURLs.count, + "originalImageURLsCount": originalImageURLs.count, + "thumbnailURLsCount": thumbnailURLs.count + ] + as [String: Any] + ) return "GalleryState(\(params))" } } diff --git a/EhPanda/Models/Persistent/AppEnv.swift b/EhPanda/Models/Persistent/AppEnv.swift index e1cab1ef..e77dafef 100644 --- a/EhPanda/Models/Persistent/AppEnv.swift +++ b/EhPanda/Models/Persistent/AppEnv.swift @@ -5,7 +5,7 @@ // Created by 荒木辰造 on R 4/02/04. // -struct AppEnv: Codable { +struct AppEnv: Codable, Equatable { let user: User let setting: Setting let searchFilter: Filter @@ -18,13 +18,16 @@ struct AppEnv: Codable { extension AppEnv: CustomStringConvertible { var description: String { - let params = String(describing: [ - "user": user, - "setting": setting, - "tagTranslator": tagTranslator, - "historyKeywordsCount": historyKeywords.count, - "quickSearchWordsCount": quickSearchWords.count - ]) + let params = String( + describing: [ + "user": user, + "setting": setting, + "tagTranslator": tagTranslator, + "historyKeywordsCount": historyKeywords.count, + "quickSearchWordsCount": quickSearchWords.count + ] + as [String: Any] + ) return "AppEnv(\(params))" } } diff --git a/EhPanda/Models/Persistent/Setting.swift b/EhPanda/Models/Persistent/Setting.swift index 2b1d5ef5..fd3c17e9 100644 --- a/EhPanda/Models/Persistent/Setting.swift +++ b/EhPanda/Models/Persistent/Setting.swift @@ -68,6 +68,15 @@ enum GalleryHost: String, Codable, Equatable, CaseIterable, Identifiable { return Defaults.URL.exhentai } } + var cookieURLs: [URL] { + switch self { + case .ehentai: + return [Defaults.URL.ehentai] + + case .exhentai: + return [Defaults.URL.exhentai, Defaults.URL.sexhentai] + } + } var abbr: String { switch self { case .ehentai: diff --git a/EhPanda/Models/Support/EhSetting.swift b/EhPanda/Models/Support/EhSetting.swift index 855ebd2c..75c27730 100644 --- a/EhPanda/Models/Support/EhSetting.swift +++ b/EhPanda/Models/Support/EhSetting.swift @@ -8,7 +8,7 @@ // MARK: EhSetting struct EhSetting: Equatable { // swiftlint:disable line_length - static let empty: Self = .init(ehProfiles: [.empty], isCapableOfCreatingNewProfile: true, capableLoadThroughHathSetting: .anyClient, capableImageResolution: .auto, capableSearchResultCount: .fifty, capableThumbnailConfigSize: .normal, capableThumbnailConfigRowCount: .forty, loadThroughHathSetting: .anyClient, browsingCountry: .autoDetect, literalBrowsingCountry: "", imageResolution: .auto, imageSizeWidth: 0, imageSizeHeight: 0, galleryName: .default, archiverBehavior: .autoSelectOriginalAutoStart, displayMode: .compact, disabledCategories: Array(repeating: false, count: 10), favoriteCategories: Array(repeating: "", count: 10), favoritesSortOrder: .favoritedTime, ratingsColor: "", excludedNamespaces: Array(repeating: false, count: 11), tagFilteringThreshold: 0, tagWatchingThreshold: 0, excludedLanguages: Array(repeating: false, count: 50), excludedUploaders: "", searchResultCount: .fifty, thumbnailLoadTiming: .onPageLoad, thumbnailConfigSize: .normal, thumbnailConfigRows: .ten, thumbnailScaleFactor: 0, viewportVirtualWidth: 0, commentsSortOrder: .recent, commentVotesShowTiming: .always, tagsSortOrder: .alphabetical, galleryShowPageNumbers: true) + static let empty: Self = .init(ehProfiles: [.empty], isCapableOfCreatingNewProfile: true, capableLoadThroughHathSetting: .anyClient, capableImageResolution: .auto, capableSearchResultCount: .fifty, capableThumbnailConfigSize: .normal, capableThumbnailConfigRowCount: .forty, loadThroughHathSetting: .anyClient, browsingCountry: .autoDetect, literalBrowsingCountry: "", imageResolution: .auto, imageSizeWidth: 0, imageSizeHeight: 0, galleryName: .default, archiverBehavior: .autoSelectOriginalAutoStart, displayMode: .compact, showSearchRangeIndicator: true, disabledCategories: Array(repeating: false, count: 10), favoriteCategories: Array(repeating: "", count: 10), favoritesSortOrder: .favoritedTime, ratingsColor: "", tagFilteringThreshold: 0, tagWatchingThreshold: 0, showFilteredRemovalCount: true, excludedLanguages: Array(repeating: false, count: 50), excludedUploaders: "", searchResultCount: .fifty, thumbnailLoadTiming: .onPageLoad, thumbnailConfigSize: .normal, thumbnailConfigRows: .ten, thumbnailScaleFactor: 0, viewportVirtualWidth: 0, commentsSortOrder: .recent, commentVotesShowTiming: .always, tagsSortOrder: .alphabetical, galleryShowPageNumbers: true) // swiftlint:enable line_length static let categoryNames = Category.allFiltersCases.map(\.rawValue).map { value in @@ -76,14 +76,14 @@ struct EhSetting: Equatable { var galleryName: GalleryName var archiverBehavior: ArchiverBehavior var displayMode: DisplayMode + var showSearchRangeIndicator: Bool var disabledCategories: [Bool] var favoriteCategories: [String] var favoritesSortOrder: FavoritesSortOrder var ratingsColor: String - var excludedNamespaces: [Bool] var tagFilteringThreshold: Float var tagWatchingThreshold: Float - var showFilteredRemovalCount: Bool? + var showFilteredRemovalCount: Bool var excludedLanguages: [Bool] var excludedUploaders: String var searchResultCount: SearchResultCount diff --git a/EhPanda/Network/Request.swift b/EhPanda/Network/Request.swift index d6858032..768031bb 100644 --- a/EhPanda/Network/Request.swift +++ b/EhPanda/Network/Request.swift @@ -16,7 +16,7 @@ protocol Request { var publisher: AnyPublisher { get } } extension Request { - var effect: Effect, Never> { + var effect: EffectTask> { publisher.receive(on: DispatchQueue.main).catchToEffect() } @@ -612,9 +612,9 @@ struct GalleryMPVImageURLRequest: Request { let index: Int let mpvKey: String let mpvImageKey: String - let skipServerIdentifier: String? + let skipServerIdentifier: Int? - var publisher: AnyPublisher<(URL, URL?, String), AppError> { + var publisher: AnyPublisher<(URL, URL?, Int), AppError> { var params: [String: Any] = [ "method": "imagedispatch", "gid": gid, @@ -638,7 +638,7 @@ struct GalleryMPVImageURLRequest: Request { .jsonObject(with: data) as? [String: Any], let imageURLString = dict["i"] as? String, let imageURL = URL(string: imageURLString), - let skipServerIdentifier = dict["s"] as? String + let skipServerIdentifier = dict["s"] as? Int else { throw AppError.parseFailed } if let originalImageURLStringSlice = dict["lf"] as? String { @@ -704,8 +704,12 @@ struct IgneousRequest: Request { } } +struct VerifyEhProfileResponse: Equatable { + let profileValue: Int? + let isProfileNotFound: Bool +} struct VerifyEhProfileRequest: Request { - var publisher: AnyPublisher<(Int?, Bool), AppError> { + var publisher: AnyPublisher { URLSession.shared.dataTaskPublisher(for: Defaults.URL.uConfig) .genericRetry() .tryMap { try Kanna.HTML(html: $0.data, encoding: .utf8) } @@ -772,10 +776,12 @@ struct SubmitEhSettingChangesRequest: Request { "tl": String(ehSetting.galleryName.rawValue), "ar": String(ehSetting.archiverBehavior.rawValue), "dm": String(ehSetting.displayMode.rawValue), + "pp": ehSetting.showSearchRangeIndicator ? "0" : "1", "fs": String(ehSetting.favoritesSortOrder.rawValue), "ru": ehSetting.ratingsColor, "ft": String(Int(ehSetting.tagFilteringThreshold)), "wt": String(Int(ehSetting.tagWatchingThreshold)), + "tf": ehSetting.showFilteredRemovalCount ? "0" : "1", "xu": ehSetting.excludedUploaders, "rc": String(ehSetting.searchResultCount.rawValue), "lt": String(ehSetting.thumbnailLoadTiming.rawValue), @@ -796,18 +802,12 @@ struct SubmitEhSettingChangesRequest: Request { Array(0...9).forEach { index in params["favorite_\(index)"] = ehSetting.favoriteCategories[index] } - Array(0...10).forEach { index in - params["xn_\(index + 1)"] = ehSetting.excludedNamespaces[index] ? "1" : "0" - } ehSetting.excludedLanguages.enumerated().forEach { index, value in if value { params["xl_\(EhSetting.languageValues[index])"] = "on" } } - if let showFilteredRemovalCount = ehSetting.showFilteredRemovalCount { - params["tf"] = showFilteredRemovalCount ? "0" : "1" - } if let useOriginalImages = ehSetting.useOriginalImages { params["oi"] = useOriginalImages ? "1" : "0" } diff --git a/EhPanda/View/Detail/Archives/ArchivesReducer.swift b/EhPanda/View/Detail/Archives/ArchivesReducer.swift new file mode 100644 index 00000000..3e180958 --- /dev/null +++ b/EhPanda/View/Detail/Archives/ArchivesReducer.swift @@ -0,0 +1,147 @@ +// +// ArchivesReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/19. +// + +import Foundation +import TTProgressHUD +import ComposableArchitecture + +struct ArchivesReducer: ReducerProtocol { + enum Route { + case messageHUD + case communicatingHUD + } + + private enum CancelID: CaseIterable { + case fetchArchive, fetchArchiveFunds, fetchDownloadResponse + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var selectedArchive: GalleryArchive.HathArchive? + + var loadingState: LoadingState = .idle + var hathArchives = [GalleryArchive.HathArchive]() + + var messageHUDConfig = TTProgressHUDConfig() + var communicatingHUDConfig: TTProgressHUDConfig = .communicating + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + + case syncGalleryFunds(String, String) + + case teardown + case fetchArchive(String, URL, URL) + case fetchArchiveDone(String, URL, Result<(GalleryArchive, String?, String?), AppError>) + case fetchArchiveFunds(String, URL) + case fetchArchiveFundsDone(Result<(String, String), AppError>) + case fetchDownloadResponse(URL) + case fetchDownloadResponseDone(Result) + } + + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.cookieClient) private var cookieClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .syncGalleryFunds(let galleryPoints, let credits): + return databaseClient + .updateGalleryFunds(galleryPoints: galleryPoints, credits: credits).fireAndForget() + + case .teardown: + return .cancel(ids: CancelID.allCases) + + case .fetchArchive(let gid, let galleryURL, let archiveURL): + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + return GalleryArchiveRequest(archiveURL: archiveURL) + .effect.map({ Action.fetchArchiveDone(gid, galleryURL, $0) }) + .cancellable(id: CancelID.fetchArchive) + + case .fetchArchiveDone(let gid, let galleryURL, let result): + state.loadingState = .idle + switch result { + case .success(let (archive, galleryPoints, credits)): + guard !archive.hathArchives.isEmpty else { + state.loadingState = .failed(.notFound) + return .none + } + state.hathArchives = archive.hathArchives + if let galleryPoints = galleryPoints, let credits = credits { + return .init(value: .syncGalleryFunds(galleryPoints, credits)) + } else if cookieClient.isSameAccount { + return .init(value: .fetchArchiveFunds(gid, galleryURL)) + } else { + return .none + } + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .fetchArchiveFunds(let gid, let galleryURL): + guard let galleryURL = galleryURL.replaceHost(to: Defaults.URL.ehentai.host) else { return .none } + return GalleryArchiveFundsRequest(gid: gid, galleryURL: galleryURL) + .effect.map(Action.fetchArchiveFundsDone).cancellable(id: CancelID.fetchArchiveFunds) + + case .fetchArchiveFundsDone(let result): + if case .success(let (galleryPoints, credits)) = result { + return .init(value: .syncGalleryFunds(galleryPoints, credits)) + } + return .none + + case .fetchDownloadResponse(let archiveURL): + guard let selectedArchive = state.selectedArchive, + state.route != .communicatingHUD + else { return .none } + state.route = .communicatingHUD + return SendDownloadCommandRequest( + archiveURL: archiveURL, resolution: selectedArchive.resolution.parameter + ) + .effect.map(Action.fetchDownloadResponseDone).cancellable(id: CancelID.fetchDownloadResponse) + + case .fetchDownloadResponseDone(let result): + state.route = .messageHUD + let isSuccess: Bool + switch result { + case .success(let response): + switch response { + case L10n.Constant.Website.Response.hathClientNotFound: + state.messageHUDConfig = .error(caption: L10n.Localizable.Website.Response.hathClientNotFound) + isSuccess = false + case L10n.Constant.Website.Response.hathClientNotOnline: + state.messageHUDConfig = .error(caption: L10n.Localizable.Website.Response.hathClientNotOnline) + isSuccess = false + case L10n.Constant.Website.Response.invalidResolution: + state.messageHUDConfig = .error(caption: L10n.Localizable.Website.Response.invalidResolution) + isSuccess = false + default: + state.messageHUDConfig = .success(caption: response) + isSuccess = true + } + case .failure: + state.messageHUDConfig = .error + isSuccess = false + } + return .fireAndForget({ hapticsClient.generateNotificationFeedback(isSuccess ? .success : .error) }) + } + } + } +} diff --git a/EhPanda/View/Detail/ArchivesView.swift b/EhPanda/View/Detail/Archives/ArchivesView.swift similarity index 92% rename from EhPanda/View/Detail/ArchivesView.swift rename to EhPanda/View/Detail/Archives/ArchivesView.swift index 3ded93ac..9ae731f3 100644 --- a/EhPanda/View/Detail/ArchivesView.swift +++ b/EhPanda/View/Detail/Archives/ArchivesView.swift @@ -9,15 +9,15 @@ import SwiftUI import ComposableArchitecture struct ArchivesView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let gid: String private let user: User private let galleryURL: URL private let archiveURL: URL init( - store: Store, + store: StoreOf, gid: String, user: User, galleryURL: URL, archiveURL: URL ) { self.store = store @@ -56,12 +56,12 @@ struct ArchivesView: View { .progressHUD( config: viewStore.communicatingHUDConfig, unwrapping: viewStore.binding(\.$route), - case: /ArchivesState.Route.communicatingHUD + case: /ArchivesReducer.Route.communicatingHUD ) .progressHUD( config: viewStore.messageHUDConfig, unwrapping: viewStore.binding(\.$route), - case: /ArchivesState.Route.messageHUD + case: /ArchivesReducer.Route.messageHUD ) .animation(.default, value: viewStore.hathArchives) .animation(.default, value: user.galleryPoints) @@ -98,7 +98,7 @@ private struct HathArchivesView: View { Button { if archive.isValid { selection = archive - HapticUtil.generateFeedback(style: .soft) + HapticsUtil.generateFeedback(style: .soft) } } label: { HathArchiveGrid(isSelected: selection == archive, archive: archive) @@ -223,12 +223,7 @@ struct ArchivesView_Previews: PreviewProvider { ArchivesView( store: .init( initialState: .init(), - reducer: archivesReducer, - environment: ArchivesEnvironment( - hapticClient: .live, - cookiesClient: .live, - databaseClient: .live - ) + reducer: ArchivesReducer() ), gid: .init(), user: .init(), diff --git a/EhPanda/View/Detail/Comments/CommentsReducer.swift b/EhPanda/View/Detail/Comments/CommentsReducer.swift new file mode 100644 index 00000000..98ebffaf --- /dev/null +++ b/EhPanda/View/Detail/Comments/CommentsReducer.swift @@ -0,0 +1,226 @@ +// +// CommentsReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/16. +// + +import Foundation +import TTProgressHUD +import ComposableArchitecture + +struct CommentsReducer: ReducerProtocol { + enum Route: Equatable { + case hud + case detail(String) + case postComment(String) + } + + private enum CancelID: CaseIterable { + case postComment, voteComment, fetchGallery + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var commentContent = "" + @BindingState var postCommentFocused = false + + var hudConfig: TTProgressHUDConfig = .loading + var scrollCommentID: String? + var scrollRowOpacity: Double = 1 + + @Heap var detailState: DetailReducer.State! + + init() { + _detailState = .init(.init()) + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case clearSubStates + case clearScrollCommentID + + case setHUDConfig(TTProgressHUDConfig) + case setPostCommentFocused(Bool) + case setScrollRowOpacity(Double) + case setCommentContent(String) + case performScrollOpacityEffect + case handleCommentLink(URL) + case handleGalleryLink(URL) + case onPostCommentAppear + case onAppear + + case updateReadingProgress(String, Int) + + case teardown + case postComment(URL, String? = nil) + case voteComment(String, String, String, String, Int) + case performCommentActionDone(Result) + case fetchGallery(URL, Bool) + case fetchGalleryDone(URL, Result) + + case detail(DetailReducer.Action) + } + + @Dependency(\.uiApplicationClient) private var uiApplicationClient + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.cookieClient) private var cookieClient + @Dependency(\.urlClient) private var urlClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.detailState = .init() + state.commentContent = .init() + state.postCommentFocused = false + return .init(value: .detail(.teardown)) + + case .clearScrollCommentID: + state.scrollCommentID = nil + return .none + + case .setHUDConfig(let config): + state.hudConfig = config + return .none + + case .setPostCommentFocused(let isFocused): + state.postCommentFocused = isFocused + return .none + + case .setScrollRowOpacity(let opacity): + state.scrollRowOpacity = opacity + return .none + + case .setCommentContent(let content): + state.commentContent = content + return .none + + case .performScrollOpacityEffect: + return .merge( + .init(value: .setScrollRowOpacity(0.25)) + .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect(), + .init(value: .setScrollRowOpacity(1)) + .delay(for: .milliseconds(1250), scheduler: DispatchQueue.main).eraseToEffect(), + .init(value: .clearScrollCommentID) + .delay(for: .milliseconds(2000), scheduler: DispatchQueue.main).eraseToEffect() + ) + + case .handleCommentLink(let url): + guard urlClient.checkIfHandleable(url) else { + return uiApplicationClient.openURL(url).fireAndForget() + } + let (isGalleryImageURL, _, _) = urlClient.analyzeURL(url) + let gid = urlClient.parseGalleryID(url) + guard databaseClient.fetchGallery(gid: gid) == nil else { + return .init(value: .handleGalleryLink(url)) + } + return .init(value: .fetchGallery(url, isGalleryImageURL)) + + case .handleGalleryLink(let url): + let (_, pageIndex, commentID) = urlClient.analyzeURL(url) + let gid = urlClient.parseGalleryID(url) + var effects = [EffectTask]() + if let pageIndex = pageIndex { + effects.append(.init(value: .updateReadingProgress(gid, pageIndex))) + effects.append( + .init(value: .detail(.setNavigation(.reading))) + .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect() + ) + } else if let commentID = commentID { + state.detailState.commentsState?.scrollCommentID = commentID + effects.append( + .init(value: .detail(.setNavigation(.comments(url)))) + .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect() + ) + } + effects.append(.init(value: .setNavigation(.detail(gid)))) + return .merge(effects) + + case .onPostCommentAppear: + return .init(value: .setPostCommentFocused(true)) + .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect() + + case .onAppear: + if state.detailState == nil { + state.detailState = .init() + } + return state.scrollCommentID != nil ? .init(value: .performScrollOpacityEffect) : .none + + case .updateReadingProgress(let gid, let progress): + guard !gid.isEmpty else { return .none } + return databaseClient + .updateReadingProgress(gid: gid, progress: progress).fireAndForget() + + case .teardown: + return .cancel(ids: CancelID.allCases) + + case .postComment(let galleryURL, let commentID): + guard !state.commentContent.isEmpty else { return .none } + if let commentID = commentID { + return EditGalleryCommentRequest( + commentID: commentID, content: state.commentContent, galleryURL: galleryURL + ) + .effect.map(Action.performCommentActionDone).cancellable(id: CancelID.postComment) + } else { + return CommentGalleryRequest(content: state.commentContent, galleryURL: galleryURL) + .effect.map(Action.performCommentActionDone).cancellable(id: CancelID.postComment) + } + + case .voteComment(let gid, let token, let apiKey, let commentID, let vote): + guard let gid = Int(gid), let commentID = Int(commentID), + let apiuid = Int(cookieClient.apiuid) + else { return .none } + return VoteGalleryCommentRequest( + apiuid: apiuid, apikey: apiKey, gid: gid, token: token, + commentID: commentID, commentVote: vote + ) + .effect.map(Action.performCommentActionDone).cancellable(id: CancelID.voteComment) + + case .performCommentActionDone: + return .none + + case .fetchGallery(let url, let isGalleryImageURL): + state.route = .hud + return GalleryReverseRequest(url: url, isGalleryImageURL: isGalleryImageURL) + .effect.map({ Action.fetchGalleryDone(url, $0) }).cancellable(id: CancelID.fetchGallery) + + case .fetchGalleryDone(let url, let result): + state.route = nil + switch result { + case .success(let gallery): + return .merge( + databaseClient.cacheGalleries([gallery]).fireAndForget(), + .init(value: .handleGalleryLink(url)) + ) + case .failure: + return .init(value: .setHUDConfig(.error)) + .delay(for: .milliseconds(500), scheduler: DispatchQueue.main).eraseToEffect() + } + + case .detail: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /Route.postComment, + hapticsClient: hapticsClient + ) + } +} diff --git a/EhPanda/View/Detail/CommentsView.swift b/EhPanda/View/Detail/Comments/CommentsView.swift similarity index 91% rename from EhPanda/View/Detail/CommentsView.swift rename to EhPanda/View/Detail/Comments/CommentsView.swift index e437e1e1..27534aab 100644 --- a/EhPanda/View/Detail/CommentsView.swift +++ b/EhPanda/View/Detail/Comments/CommentsView.swift @@ -10,8 +10,8 @@ import Kingfisher import ComposableArchitecture struct CommentsView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let gid: String private let token: String private let apiKey: String @@ -23,7 +23,7 @@ struct CommentsView: View { private let tagTranslator: TagTranslator init( - store: Store, + store: StoreOf, gid: String, token: String, apiKey: String, galleryURL: URL, comments: [GalleryComment], user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator @@ -92,7 +92,7 @@ struct CommentsView: View { } } } - .sheet(unwrapping: viewStore.binding(\.$route), case: /CommentsState.Route.postComment) { route in + .sheet(unwrapping: viewStore.binding(\.$route), case: /CommentsReducer.Route.postComment) { route in let hasCommentID = !route.wrappedValue.isEmpty PostCommentView( title: hasCommentID @@ -117,7 +117,7 @@ struct CommentsView: View { .progressHUD( config: viewStore.hudConfig, unwrapping: viewStore.binding(\.$route), - case: /CommentsState.Route.hud + case: /CommentsReducer.Route.hud ) .animation(.default, value: viewStore.scrollRowOpacity) .onAppear { @@ -135,7 +135,7 @@ struct CommentsView: View { } label: { Image(systemSymbol: .squareAndPencil) } - .disabled(!CookiesUtil.didLogin) + .disabled(!CookieUtil.didLogin) } } } @@ -143,9 +143,9 @@ struct CommentsView: View { // MARK: NavigationLinks private extension CommentsView { @ViewBuilder var navigationLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /CommentsState.Route.detail) { route in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /CommentsReducer.Route.detail) { route in DetailView( - store: store.scope(state: \.detailState, action: CommentsAction.detail), + store: store.scope(state: \.detailState, action: CommentsReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) @@ -274,19 +274,7 @@ struct CommentsView_Previews: PreviewProvider { CommentsView( store: .init( initialState: .init(), - reducer: commentsReducer, - environment: CommentsEnvironment( - urlClient: .live, - fileClient: .live, - imageClient: .live, - deviceClient: .live, - hapticClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live, - uiApplicationClient: .live - ) + reducer: CommentsReducer() ), gid: .init(), token: .init(), diff --git a/EhPanda/View/Detail/Support/LinkedText.swift b/EhPanda/View/Detail/Components/LinkedText.swift similarity index 100% rename from EhPanda/View/Detail/Support/LinkedText.swift rename to EhPanda/View/Detail/Components/LinkedText.swift diff --git a/EhPanda/View/Detail/Support/PostCommentView.swift b/EhPanda/View/Detail/Components/PostCommentView.swift similarity index 100% rename from EhPanda/View/Detail/Support/PostCommentView.swift rename to EhPanda/View/Detail/Components/PostCommentView.swift diff --git a/EhPanda/View/Detail/Support/RatingView.swift b/EhPanda/View/Detail/Components/RatingView.swift similarity index 100% rename from EhPanda/View/Detail/Support/RatingView.swift rename to EhPanda/View/Detail/Components/RatingView.swift diff --git a/EhPanda/View/Detail/Support/TagDetailView.swift b/EhPanda/View/Detail/Components/TagDetailView.swift similarity index 100% rename from EhPanda/View/Detail/Support/TagDetailView.swift rename to EhPanda/View/Detail/Components/TagDetailView.swift diff --git a/EhPanda/View/Detail/DataFlow/ArchivesStore.swift b/EhPanda/View/Detail/DataFlow/ArchivesStore.swift deleted file mode 100644 index 4c7de883..00000000 --- a/EhPanda/View/Detail/DataFlow/ArchivesStore.swift +++ /dev/null @@ -1,138 +0,0 @@ -// -// ArchivesStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/19. -// - -import TTProgressHUD -import ComposableArchitecture - -struct ArchivesState: Equatable { - enum Route { - case messageHUD - case communicatingHUD - } - struct CancelID: Hashable { - let id = String(describing: ArchivesState.self) - } - - @BindableState var route: Route? - @BindableState var selectedArchive: GalleryArchive.HathArchive? - - var loadingState: LoadingState = .idle - var hathArchives = [GalleryArchive.HathArchive]() - - var messageHUDConfig = TTProgressHUDConfig() - var communicatingHUDConfig: TTProgressHUDConfig = .communicating -} - -enum ArchivesAction: BindableAction { - case binding(BindingAction) - case setNavigation(ArchivesState.Route?) - - case syncGalleryFunds(String, String) - - case teardown - case fetchArchive(String, URL, URL) - case fetchArchiveDone(String, URL, Result<(GalleryArchive, String?, String?), AppError>) - case fetchArchiveFunds(String, URL) - case fetchArchiveFundsDone(Result<(String, String), AppError>) - case fetchDownloadResponse(URL) - case fetchDownloadResponseDone(Result) -} - -struct ArchivesEnvironment { - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient -} - -let archivesReducer = Reducer { state, action, environment in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return .none - - case .syncGalleryFunds(let galleryPoints, let credits): - return environment.databaseClient - .updateGalleryFunds(galleryPoints: galleryPoints, credits: credits).fireAndForget() - - case .teardown: - return .cancel(id: ArchivesState.CancelID()) - - case .fetchArchive(let gid, let galleryURL, let archiveURL): - guard state.loadingState != .loading else { return .none } - state.loadingState = .loading - return GalleryArchiveRequest(archiveURL: archiveURL) - .effect.map({ ArchivesAction.fetchArchiveDone(gid, galleryURL, $0) }) - .cancellable(id: ArchivesState.CancelID()) - - case .fetchArchiveDone(let gid, let galleryURL, let result): - state.loadingState = .idle - switch result { - case .success(let (archive, galleryPoints, credits)): - guard !archive.hathArchives.isEmpty else { - state.loadingState = .failed(.notFound) - return .none - } - state.hathArchives = archive.hathArchives - if let galleryPoints = galleryPoints, let credits = credits { - return .init(value: .syncGalleryFunds(galleryPoints, credits)) - } else if environment.cookiesClient.isSameAccount { - return .init(value: .fetchArchiveFunds(gid, galleryURL)) - } else { - return .none - } - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .fetchArchiveFunds(let gid, let galleryURL): - guard let galleryURL = galleryURL.replaceHost(to: Defaults.URL.ehentai.host) else { return .none } - return GalleryArchiveFundsRequest(gid: gid, galleryURL: galleryURL) - .effect.map(ArchivesAction.fetchArchiveFundsDone).cancellable(id: ArchivesState.CancelID()) - - case .fetchArchiveFundsDone(let result): - if case .success(let (galleryPoints, credits)) = result { - return .init(value: .syncGalleryFunds(galleryPoints, credits)) - } - return .none - - case .fetchDownloadResponse(let archiveURL): - guard let selectedArchive = state.selectedArchive, state.route != .communicatingHUD else { return .none } - state.route = .communicatingHUD - return SendDownloadCommandRequest(archiveURL: archiveURL, resolution: selectedArchive.resolution.parameter) - .effect.map(ArchivesAction.fetchDownloadResponseDone).cancellable(id: ArchivesState.CancelID()) - - case .fetchDownloadResponseDone(let result): - state.route = .messageHUD - let isSuccess: Bool - switch result { - case .success(let response): - switch response { - case L10n.Constant.Website.Response.hathClientNotFound: - state.messageHUDConfig = .error(caption: L10n.Localizable.Website.Response.hathClientNotFound) - isSuccess = false - case L10n.Constant.Website.Response.hathClientNotOnline: - state.messageHUDConfig = .error(caption: L10n.Localizable.Website.Response.hathClientNotOnline) - isSuccess = false - case L10n.Constant.Website.Response.invalidResolution: - state.messageHUDConfig = .error(caption: L10n.Localizable.Website.Response.invalidResolution) - isSuccess = false - default: - state.messageHUDConfig = .success(caption: response) - isSuccess = true - } - case .failure: - state.messageHUDConfig = .error - isSuccess = false - } - return environment.hapticClient.generateNotificationFeedback(isSuccess ? .success : .error).fireAndForget() - } -} -.binding() diff --git a/EhPanda/View/Detail/DataFlow/CommentsStore.swift b/EhPanda/View/Detail/DataFlow/CommentsStore.swift deleted file mode 100644 index e1e94597..00000000 --- a/EhPanda/View/Detail/DataFlow/CommentsStore.swift +++ /dev/null @@ -1,226 +0,0 @@ -// -// CommentsStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/16. -// - -import TTProgressHUD -import ComposableArchitecture - -struct CommentsState: Equatable { - enum Route: Equatable { - case hud - case detail(String) - case postComment(String) - } - struct CancelID: Hashable { - let id = String(describing: CommentsState.self) - } - - init() { - _detailState = .init(.init()) - } - - @BindableState var route: Route? - @BindableState var commentContent = "" - @BindableState var postCommentFocused = false - - var hudConfig: TTProgressHUDConfig = .loading - var scrollCommentID: String? - var scrollRowOpacity: Double = 1 - - @Heap var detailState: DetailState! -} - -enum CommentsAction: BindableAction { - case binding(BindingAction) - case setNavigation(CommentsState.Route?) - case clearSubStates - case clearScrollCommentID - - case setHUDConfig(TTProgressHUDConfig) - case setPostCommentFocused(Bool) - case setScrollRowOpacity(Double) - case setCommentContent(String) - case performScrollOpacityEffect - case handleCommentLink(URL) - case handleGalleryLink(URL) - case onPostCommentAppear - case onAppear - - case updateReadingProgress(String, Int) - - case teardown - case postComment(URL, String? = nil) - case voteComment(String, String, String, String, Int) - case performCommentActionDone(Result) - case fetchGallery(URL, Bool) - case fetchGalleryDone(URL, Result) - - case detail(DetailAction) -} - -struct CommentsEnvironment { - let urlClient: URLClient - let fileClient: FileClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient - let uiApplicationClient: UIApplicationClient -} - -let commentsReducer = Reducer { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .clearSubStates: - state.detailState = .init() - state.commentContent = .init() - state.postCommentFocused = false - return .init(value: .detail(.teardown)) - - case .clearScrollCommentID: - state.scrollCommentID = nil - return .none - - case .setHUDConfig(let config): - state.hudConfig = config - return .none - - case .setPostCommentFocused(let isFocused): - state.postCommentFocused = isFocused - return .none - - case .setScrollRowOpacity(let opacity): - state.scrollRowOpacity = opacity - return .none - - case .setCommentContent(let content): - state.commentContent = content - return .none - - case .performScrollOpacityEffect: - return .merge( - .init(value: .setScrollRowOpacity(0.25)) - .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect(), - .init(value: .setScrollRowOpacity(1)) - .delay(for: .milliseconds(1250), scheduler: DispatchQueue.main).eraseToEffect(), - .init(value: .clearScrollCommentID) - .delay(for: .milliseconds(2000), scheduler: DispatchQueue.main).eraseToEffect() - ) - - case .handleCommentLink(let url): - guard environment.urlClient.checkIfHandleable(url) else { - return environment.uiApplicationClient.openURL(url).fireAndForget() - } - let (isGalleryImageURL, _, _) = environment.urlClient.analyzeURL(url) - let gid = environment.urlClient.parseGalleryID(url) - guard environment.databaseClient.fetchGallery(gid: gid) == nil else { - return .init(value: .handleGalleryLink(url)) - } - return .init(value: .fetchGallery(url, isGalleryImageURL)) - - case .handleGalleryLink(let url): - let (_, pageIndex, commentID) = environment.urlClient.analyzeURL(url) - let gid = environment.urlClient.parseGalleryID(url) - var effects = [Effect]() - if let pageIndex = pageIndex { - effects.append(.init(value: .updateReadingProgress(gid, pageIndex))) - effects.append( - .init(value: .detail(.setNavigation(.reading))) - .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect() - ) - } else if let commentID = commentID { - state.detailState.commentsState?.scrollCommentID = commentID - effects.append( - .init(value: .detail(.setNavigation(.comments(url)))) - .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect() - ) - } - effects.append(.init(value: .setNavigation(.detail(gid)))) - return .merge(effects) - - case .onPostCommentAppear: - return .init(value: .setPostCommentFocused(true)) - .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect() - - case .onAppear: - if state.detailState == nil { - state.detailState = .init() - } - return state.scrollCommentID != nil ? .init(value: .performScrollOpacityEffect) : .none - - case .updateReadingProgress(let gid, let progress): - guard !gid.isEmpty else { return .none } - return environment.databaseClient - .updateReadingProgress(gid: gid, progress: progress).fireAndForget() - - case .teardown: - return .cancel(id: CommentsState.CancelID()) - - case .postComment(let galleryURL, let commentID): - guard !state.commentContent.isEmpty else { return .none } - if let commentID = commentID { - return EditGalleryCommentRequest( - commentID: commentID, content: state.commentContent, galleryURL: galleryURL - ) - .effect.map(CommentsAction.performCommentActionDone).cancellable(id: CommentsState.CancelID()) - } else { - return CommentGalleryRequest(content: state.commentContent, galleryURL: galleryURL) - .effect.map(CommentsAction.performCommentActionDone).cancellable(id: CommentsState.CancelID()) - } - - case .voteComment(let gid, let token, let apiKey, let commentID, let vote): - guard let gid = Int(gid), let commentID = Int(commentID), - let apiuid = Int(environment.cookiesClient.apiuid) - else { return .none } - return VoteGalleryCommentRequest( - apiuid: apiuid, apikey: apiKey, gid: gid, token: token, - commentID: commentID, commentVote: vote - ) - .effect.map(CommentsAction.performCommentActionDone).cancellable(id: CommentsState.CancelID()) - - case .performCommentActionDone: - return .none - - case .fetchGallery(let url, let isGalleryImageURL): - state.route = .hud - return GalleryReverseRequest(url: url, isGalleryImageURL: isGalleryImageURL) - .effect.map({ CommentsAction.fetchGalleryDone(url, $0) }).cancellable(id: CommentsState.CancelID()) - - case .fetchGalleryDone(let url, let result): - state.route = nil - switch result { - case .success(let gallery): - return .merge( - environment.databaseClient.cacheGalleries([gallery]).fireAndForget(), - .init(value: .handleGalleryLink(url)) - ) - case .failure: - return .init(value: .setHUDConfig(.error)) - .delay(for: .milliseconds(500), scheduler: DispatchQueue.main).eraseToEffect() - } - - case .detail: - return .none - } -} -.haptics( - unwrapping: \.route, - case: /CommentsState.Route.postComment, - hapticClient: \.hapticClient -) -.binding() diff --git a/EhPanda/View/Detail/DataFlow/DetailSearchStore.swift b/EhPanda/View/Detail/DataFlow/DetailSearchStore.swift deleted file mode 100644 index 3c91db39..00000000 --- a/EhPanda/View/Detail/DataFlow/DetailSearchStore.swift +++ /dev/null @@ -1,208 +0,0 @@ -// -// SearchStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/12. -// - -import ComposableArchitecture - -struct DetailSearchState: Equatable { - enum Route: Equatable { - case filters - case quickSearch - case detail(String) - } - struct CancelID: Hashable { - let id = String(describing: DetailSearchState.self) - } - - init() { - _detailState = .init(.init()) - } - - @BindableState var route: Route? - @BindableState var keyword = "" - var lastKeyword = "" - - var galleries = [Gallery]() - var pageNumber = PageNumber() - var loadingState: LoadingState = .idle - var footerLoadingState: LoadingState = .idle - - @Heap var detailState: DetailState! - var filtersState = FiltersState() - var quickDetailSearchState = QuickSearchState() - - mutating func insertGalleries(_ galleries: [Gallery]) { - galleries.forEach { gallery in - if !self.galleries.contains(gallery) { - self.galleries.append(gallery) - } - } - } -} - -enum DetailSearchAction: BindableAction { - case binding(BindingAction) - case setNavigation(DetailSearchState.Route?) - case clearSubStates - - case teardown - case fetchGalleries(String? = nil) - case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - case fetchMoreGalleries - case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - - case detail(DetailAction) - case filters(FiltersAction) - case quickSearch(QuickSearchAction) -} - -struct DetailSearchEnvironment { - let urlClient: URLClient - let fileClient: FileClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient - let uiApplicationClient: UIApplicationClient -} - -let detailSearchReducer = Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding(\.$keyword): - if !state.keyword.isEmpty { - state.lastKeyword = state.keyword - } - return .none - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .clearSubStates: - state.detailState = .init() - state.filtersState = .init() - state.quickDetailSearchState = .init() - return .merge( - .init(value: .detail(.teardown)), - .init(value: .quickSearch(.teardown)) - ) - - case .teardown: - return .cancel(id: DetailSearchState.CancelID()) - - case .fetchGalleries(let keyword): - guard state.loadingState != .loading else { return .none } - if let keyword = keyword { - state.keyword = keyword - state.lastKeyword = keyword - } - state.loadingState = .loading - state.pageNumber.resetPages() - let filter = environment.databaseClient.fetchFilterSynchronously(range: .search) - return SearchGalleriesRequest(keyword: state.lastKeyword, filter: filter).effect - .map(DetailSearchAction.fetchGalleriesDone).cancellable(id: DetailSearchState.CancelID()) - - case .fetchGalleriesDone(let result): - state.loadingState = .idle - switch result { - case .success(let (pageNumber, galleries)): - guard !galleries.isEmpty else { - state.loadingState = .failed(.notFound) - guard pageNumber.hasNextPage() else { return .none } - return .init(value: .fetchMoreGalleries) - } - state.pageNumber = pageNumber - state.galleries = galleries - return environment.databaseClient.cacheGalleries(galleries).fireAndForget() - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .fetchMoreGalleries: - let pageNumber = state.pageNumber - guard pageNumber.hasNextPage(), - state.footerLoadingState != .loading, - let lastID = state.galleries.last?.id - else { return .none } - state.footerLoadingState = .loading - let filter = environment.databaseClient.fetchFilterSynchronously(range: .search) - return MoreSearchGalleriesRequest(keyword: state.lastKeyword, filter: filter, lastID: lastID).effect - .map(DetailSearchAction.fetchMoreGalleriesDone) - .cancellable(id: DetailSearchState.CancelID()) - - case .fetchMoreGalleriesDone(let result): - state.footerLoadingState = .idle - switch result { - case .success(let (pageNumber, galleries)): - state.pageNumber = pageNumber - state.insertGalleries(galleries) - - var effects: [Effect] = [ - environment.databaseClient.cacheGalleries(galleries).fireAndForget() - ] - if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.init(value: .fetchMoreGalleries)) - } else if !galleries.isEmpty { - state.loadingState = .idle - } - return .merge(effects) - - case .failure(let error): - state.footerLoadingState = .failed(error) - } - return .none - - case .detail: - return .none - - case .filters: - return .none - - case .quickSearch: - return .none - } - } - .haptics( - unwrapping: \.route, - case: /DetailSearchState.Route.quickSearch, - hapticClient: \.hapticClient - ) - .haptics( - unwrapping: \.route, - case: /DetailSearchState.Route.filters, - hapticClient: \.hapticClient - ) - .binding(), - filtersReducer.pullback( - state: \.filtersState, - action: /DetailSearchAction.filters, - environment: { - .init( - databaseClient: $0.databaseClient - ) - } - ), - quickSearchReducer.pullback( - state: \.quickDetailSearchState, - action: /DetailSearchAction.quickSearch, - environment: { - .init( - databaseClient: $0.databaseClient - ) - } - ) -) diff --git a/EhPanda/View/Detail/DataFlow/DetailStore.swift b/EhPanda/View/Detail/DataFlow/DetailStore.swift deleted file mode 100644 index 542506de..00000000 --- a/EhPanda/View/Detail/DataFlow/DetailStore.swift +++ /dev/null @@ -1,508 +0,0 @@ -// -// DetailStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/10. -// - -import SwiftUI -import ComposableArchitecture - -struct DetailState: Equatable { - enum Route: Equatable { - case reading - case archives(URL, URL) - case torrents - case previews - case comments(URL) - case share(URL) - case postComment - case newDawn(Greeting) - case detailSearch(String) - case tagDetail(TagDetail) - case galleryInfos(Gallery, GalleryDetail) - } - struct CancelID: Hashable { - let id = String(describing: DetailState.self) - } - - init() { - _commentsState = .init(nil) - _detailSearchState = .init(nil) - } - - @BindableState var route: Route? - @BindableState var commentContent = "" - @BindableState var postCommentFocused = false - - var showsNewDawnGreeting = false - var showsUserRating = false - var showsFullTitle = false - var userRating = 0 - - var apiKey = "" - var loadingState: LoadingState = .idle - var gallery: Gallery = .empty - var galleryDetail: GalleryDetail? - var galleryTags = [GalleryTag]() - var galleryPreviewURLs = [Int: URL]() - var galleryComments = [GalleryComment]() - - var readingState = ReadingState() - var archivesState = ArchivesState() - var torrentsState = TorrentsState() - var previewsState = PreviewsState() - @Heap var commentsState: CommentsState? - var galleryInfosState = GalleryInfosState() - @Heap var detailSearchState: DetailSearchState? - - mutating func updateRating(value: DragGesture.Value) { - let rating = Int(value.location.x / 31 * 2) + 1 - userRating = min(max(rating, 1), 10) - } -} - -indirect enum DetailAction: BindableAction { - case binding(BindingAction) - case setNavigation(DetailState.Route?) - case clearSubStates - case onPostCommentAppear - case onAppear(String, Bool) - - case toggleShowFullTitle - case toggleShowUserRating - case setCommentContent(String) - case setPostCommentFocused(Bool) - case updateRating(DragGesture.Value) - case confirmRating(DragGesture.Value) - case confirmRatingDone - - case syncGalleryTags - case syncGalleryDetail - case syncGalleryPreviewURLs - case syncGalleryComments - case syncGreeting(Greeting) - case syncPreviewConfig(PreviewConfig) - case saveGalleryHistory - case updateReadingProgress(Int) - - case teardown - case fetchDatabaseInfos(String) - case fetchDatabaseInfosDone(GalleryState) - case fetchGalleryDetail - case fetchGalleryDetailDone(Result<(GalleryDetail, GalleryState, String, Greeting?), AppError>) - - case rateGallery - case favorGallery(Int) - case unfavorGallery - case postComment(URL) - case voteTag(String, Int) - case anyGalleryOpsDone(Result) - - case reading(ReadingAction) - case archives(ArchivesAction) - case torrents(TorrentsAction) - case previews(PreviewsAction) - case comments(CommentsAction) - case galleryInfos(GalleryInfosAction) - case detailSearch(DetailSearchAction) -} - -struct DetailEnvironment { - let urlClient: URLClient - let fileClient: FileClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient - let uiApplicationClient: UIApplicationClient -} - -let detailReducer = Reducer.recurse { (self) in - Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .clearSubStates: - state.readingState = .init() - state.archivesState = .init() - state.torrentsState = .init() - state.previewsState = .init() - state.commentsState = .init() - state.commentContent = .init() - state.postCommentFocused = false - state.galleryInfosState = .init() - state.detailSearchState = .init() - return .merge( - .init(value: .reading(.teardown)), - .init(value: .archives(.teardown)), - .init(value: .torrents(.teardown)), - .init(value: .previews(.teardown)), - .init(value: .comments(.teardown)), - .init(value: .detailSearch(.teardown)) - ) - - case .onPostCommentAppear: - return .init(value: .setPostCommentFocused(true)) - .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect() - - case .onAppear(let gid, let showsNewDawnGreeting): - state.showsNewDawnGreeting = showsNewDawnGreeting - if state.detailSearchState == nil { - state.detailSearchState = .init() - } - if state.commentsState == nil { - state.commentsState = .init() - } - return .init(value: .fetchDatabaseInfos(gid)) - - case .toggleShowFullTitle: - state.showsFullTitle.toggle() - return environment.hapticClient.generateFeedback(.soft).fireAndForget() - - case .toggleShowUserRating: - state.showsUserRating.toggle() - return environment.hapticClient.generateFeedback(.soft).fireAndForget() - - case .setCommentContent(let content): - state.commentContent = content - return .none - - case .setPostCommentFocused(let isFocused): - state.postCommentFocused = isFocused - return .none - - case .updateRating(let value): - state.updateRating(value: value) - return .none - - case .confirmRating(let value): - state.updateRating(value: value) - return .merge( - .init(value: .rateGallery), - environment.hapticClient.generateFeedback(.soft).fireAndForget(), - .init(value: .confirmRatingDone).delay(for: 1, scheduler: DispatchQueue.main).eraseToEffect() - ) - - case .confirmRatingDone: - state.showsUserRating = false - return .none - - case .syncGalleryTags: - return environment.databaseClient - .updateGalleryTags(gid: state.gallery.id, tags: state.galleryTags).fireAndForget() - - case .syncGalleryDetail: - guard let detail = state.galleryDetail else { return .none } - return environment.databaseClient.cacheGalleryDetail(detail).fireAndForget() - - case .syncGalleryPreviewURLs: - return environment.databaseClient - .updatePreviewURLs(gid: state.gallery.id, previewURLs: state.galleryPreviewURLs).fireAndForget() - - case .syncGalleryComments: - return environment.databaseClient - .updateComments(gid: state.gallery.id, comments: state.galleryComments).fireAndForget() - - case .syncGreeting(let greeting): - return environment.databaseClient.updateGreeting(greeting).fireAndForget() - - case .syncPreviewConfig(let config): - return environment.databaseClient - .updatePreviewConfig(gid: state.gallery.id, config: config).fireAndForget() - - case .saveGalleryHistory: - return environment.databaseClient.updateLastOpenDate(gid: state.gallery.id).fireAndForget() - - case .updateReadingProgress(let progress): - return environment.databaseClient - .updateReadingProgress(gid: state.gallery.id, progress: progress).fireAndForget() - - case .teardown: - return .cancel(id: DetailState.CancelID()) - - case .fetchDatabaseInfos(let gid): - guard let gallery = environment.databaseClient.fetchGallery(gid: gid) else { return .none } - state.gallery = gallery - if let detail = environment.databaseClient.fetchGalleryDetail(gid: gid) { - state.galleryDetail = detail - } - return .merge( - .init(value: .saveGalleryHistory), - environment.databaseClient.fetchGalleryState(gid: state.gallery.id) - .map(DetailAction.fetchDatabaseInfosDone).cancellable(id: DetailState.CancelID()) - ) - - case .fetchDatabaseInfosDone(let galleryState): - state.galleryTags = galleryState.tags - state.galleryPreviewURLs = galleryState.previewURLs - state.galleryComments = galleryState.comments - return .init(value: .fetchGalleryDetail) - - case .fetchGalleryDetail: - guard state.loadingState != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.loadingState = .loading - return GalleryDetailRequest(gid: state.gallery.id, galleryURL: galleryURL) - .effect.map(DetailAction.fetchGalleryDetailDone).cancellable(id: DetailState.CancelID()) - - case .fetchGalleryDetailDone(let result): - state.loadingState = .idle - switch result { - case .success(let (galleryDetail, galleryState, apiKey, greeting)): - var effects: [Effect] = [ - .init(value: .syncGalleryTags), - .init(value: .syncGalleryDetail), - .init(value: .syncGalleryPreviewURLs), - .init(value: .syncGalleryComments) - ] - state.apiKey = apiKey - state.galleryDetail = galleryDetail - state.galleryTags = galleryState.tags - state.galleryPreviewURLs = galleryState.previewURLs - state.galleryComments = galleryState.comments - state.userRating = Int(galleryDetail.userRating) * 2 - if let greeting = greeting { - effects.append(.init(value: .syncGreeting(greeting))) - if !greeting.gainedNothing && state.showsNewDawnGreeting { - effects.append(.init(value: .setNavigation(.newDawn(greeting)))) - } - } - if let config = galleryState.previewConfig { - effects.append(.init(value: .syncPreviewConfig(config))) - } - return .merge(effects) - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .rateGallery: - guard let apiuid = Int(environment.cookiesClient.apiuid), let gid = Int(state.gallery.id) - else { return .none } - return RateGalleryRequest( - apiuid: apiuid, apikey: state.apiKey, gid: gid, - token: state.gallery.token, rating: state.userRating - ) - .effect.map(DetailAction.anyGalleryOpsDone).cancellable(id: DetailState.CancelID()) - - case .favorGallery(let favIndex): - return FavorGalleryRequest(gid: state.gallery.id, token: state.gallery.token, favIndex: favIndex) - .effect.map(DetailAction.anyGalleryOpsDone).cancellable(id: DetailState.CancelID()) - - case .unfavorGallery: - return UnfavorGalleryRequest(gid: state.gallery.id).effect.map(DetailAction.anyGalleryOpsDone) - .cancellable(id: DetailState.CancelID()) - - case .postComment(let galleryURL): - guard !state.commentContent.isEmpty else { return .none } - return CommentGalleryRequest(content: state.commentContent, galleryURL: galleryURL) - .effect.map(DetailAction.anyGalleryOpsDone).cancellable(id: DetailState.CancelID()) - - case .voteTag(let tag, let vote): - guard let apiuid = Int(environment.cookiesClient.apiuid), let gid = Int(state.gallery.id) - else { return .none } - return VoteGalleryTagRequest( - apiuid: apiuid, apikey: state.apiKey, gid: gid, token: state.gallery.token, tag: tag, vote: vote - ) - .effect.map(DetailAction.anyGalleryOpsDone).cancellable(id: DetailState.CancelID()) - - case .anyGalleryOpsDone(let result): - if case .success = result { - return .merge( - .init(value: .fetchGalleryDetail), - environment.hapticClient.generateNotificationFeedback(.success).fireAndForget() - ) - } - return environment.hapticClient.generateNotificationFeedback(.error).fireAndForget() - - case .reading(.onPerformDismiss): - return .init(value: .setNavigation(nil)) - - case .reading: - return .none - - case .archives: - return .none - - case .torrents: - return .none - - case .previews: - return .none - - case .comments(.performCommentActionDone(let result)): - return .init(value: .anyGalleryOpsDone(result)) - - case .comments(.detail(let recursiveAction)): - guard state.commentsState != nil else { return .none } - return self.run(&state.commentsState!.detailState, recursiveAction, environment) - .map({ DetailAction.comments(.detail($0)) }) - - case .comments: - return .none - - case .galleryInfos: - return .none - - case .detailSearch(.detail(let recursiveAction)): - guard state.detailSearchState != nil else { return .none } - return self.run(&state.detailSearchState!.detailState, recursiveAction, environment) - .map({ DetailAction.detailSearch(.detail($0)) }) - - case .detailSearch: - return .none - } - } - .haptics( - unwrapping: \.route, - case: /DetailState.Route.detailSearch, - hapticClient: \.hapticClient, - style: .soft - ) - .haptics( - unwrapping: \.route, - case: /DetailState.Route.postComment, - hapticClient: \.hapticClient - ) - .haptics( - unwrapping: \.route, - case: /DetailState.Route.tagDetail, - hapticClient: \.hapticClient - ) - .haptics( - unwrapping: \.route, - case: /DetailState.Route.torrents, - hapticClient: \.hapticClient - ) - .haptics( - unwrapping: \.route, - case: /DetailState.Route.archives, - hapticClient: \.hapticClient - ) - .haptics( - unwrapping: \.route, - case: /DetailState.Route.reading, - hapticClient: \.hapticClient - ) - .haptics( - unwrapping: \.route, - case: /DetailState.Route.share, - hapticClient: \.hapticClient - ) - .binding(), - readingReducer.pullback( - state: \.readingState, - action: /DetailAction.reading, - environment: { - .init( - urlClient: $0.urlClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient - ) - } - ), - archivesReducer.pullback( - state: \.archivesState, - action: /DetailAction.archives, - environment: { - .init( - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient - ) - } - ), - torrentsReducer.pullback( - state: \.torrentsState, - action: /DetailAction.torrents, - environment: { - .init( - fileClient: $0.fileClient, - hapticClient: $0.hapticClient, - clipboardClient: $0.clipboardClient - ) - } - ), - previewsReducer.pullback( - state: \.previewsState, - action: /DetailAction.previews, - environment: { - .init( - urlClient: $0.urlClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient - ) - } - ), - commentsReducer.optional().pullback( - state: \.commentsState, - action: /DetailAction.comments, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ), - detailSearchReducer.optional().pullback( - state: \.detailSearchState, - action: /DetailAction.detailSearch, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ), - galleryInfosReducer.pullback( - state: \.galleryInfosState, - action: /DetailAction.galleryInfos, - environment: { - .init( - hapticClient: $0.hapticClient, - clipboardClient: $0.clipboardClient - ) - } - ) - ) -} diff --git a/EhPanda/View/Detail/DataFlow/GalleryInfosStore.swift b/EhPanda/View/Detail/DataFlow/GalleryInfosStore.swift deleted file mode 100644 index d6ae0812..00000000 --- a/EhPanda/View/Detail/DataFlow/GalleryInfosStore.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// GalleryInfosStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/23. -// - -import TTProgressHUD -import ComposableArchitecture - -struct GalleryInfosState: Equatable { - enum Route { - case hud - } - - @BindableState var route: Route? - var hudConfig: TTProgressHUDConfig = .copiedToClipboardSucceeded -} - -enum GalleryInfosAction: BindableAction { - case binding(BindingAction) - case copyText(String) -} - -struct GalleryInfosEnvironment { - let hapticClient: HapticClient - let clipboardClient: ClipboardClient -} - -let galleryInfosReducer = Reducer -{ state, action, environment in - switch action { - case .binding: - return .none - - case .copyText(let text): - state.route = .hud - return .merge( - environment.clipboardClient.saveText(text).fireAndForget(), - environment.hapticClient.generateNotificationFeedback(.success).fireAndForget() - ) - } -} -.binding() diff --git a/EhPanda/View/Detail/DataFlow/PreviewsStore.swift b/EhPanda/View/Detail/DataFlow/PreviewsStore.swift deleted file mode 100644 index 41f41ade..00000000 --- a/EhPanda/View/Detail/DataFlow/PreviewsStore.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// PreviewsStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/16. -// - -import ComposableArchitecture - -struct PreviewsState: Equatable { - enum Route { - case reading - } - struct CancelID: Hashable { - let id = String(describing: PreviewsState.self) - } - - @BindableState var route: Route? - - var gallery: Gallery = .empty - var loadingState: LoadingState = .idle - var databaseLoadingState: LoadingState = .loading - - var previewURLs = [Int: URL]() - var previewConfig: PreviewConfig = .normal(rows: 4) - - var readingState = ReadingState() - - mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { - self.previewURLs = self.previewURLs.merging( - previewURLs, uniquingKeysWith: { stored, _ in stored } - ) - } -} - -enum PreviewsAction: BindableAction { - case binding(BindingAction) - case setNavigation(PreviewsState.Route?) - case clearSubStates - - case syncPreviewURLs([Int: URL]) - case updateReadingProgress(Int) - - case teardown - case fetchDatabaseInfos(String) - case fetchDatabaseInfosDone(GalleryState) - case fetchPreviewURLs(Int) - case fetchPreviewURLsDone(Result<[Int: URL], AppError>) - - case reading(ReadingAction) -} - -struct PreviewsEnvironment { - let urlClient: URLClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient -} - -let previewsReducer = Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .clearSubStates: - state.readingState = .init() - return .init(value: .reading(.teardown)) - - case .syncPreviewURLs(let previewURLs): - return environment.databaseClient - .updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs).fireAndForget() - - case .updateReadingProgress(let progress): - return environment.databaseClient - .updateReadingProgress(gid: state.gallery.id, progress: progress).fireAndForget() - - case .teardown: - return .cancel(id: PreviewsState.CancelID()) - - case .fetchDatabaseInfos(let gid): - guard let gallery = environment.databaseClient.fetchGallery(gid: gid) else { return .none } - state.gallery = gallery - return environment.databaseClient.fetchGalleryState(gid: state.gallery.id) - .map(PreviewsAction.fetchDatabaseInfosDone).cancellable(id: PreviewsState.CancelID()) - - case .fetchDatabaseInfosDone(let galleryState): - if let previewConfig = galleryState.previewConfig { - state.previewConfig = previewConfig - } - state.previewURLs = galleryState.previewURLs - state.databaseLoadingState = .idle - return .none - - case .fetchPreviewURLs(let index): - guard state.loadingState != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.loadingState = .loading - let pageNum = state.previewConfig.pageNumber(index: index) - return GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum) - .effect.map(PreviewsAction.fetchPreviewURLsDone).cancellable(id: PreviewsState.CancelID()) - - case .fetchPreviewURLsDone(let result): - state.loadingState = .idle - - switch result { - case .success(let previewURLs): - guard !previewURLs.isEmpty else { - state.loadingState = .failed(.notFound) - return .none - } - state.updatePreviewURLs(previewURLs) - return .init(value: .syncPreviewURLs(previewURLs)) - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .reading(.onPerformDismiss): - return .init(value: .setNavigation(nil)) - - case .reading: - return .none - } - } - .haptics( - unwrapping: \.route, - case: /PreviewsState.Route.reading, - hapticClient: \.hapticClient - ) - .binding(), - readingReducer.pullback( - state: \.readingState, - action: /PreviewsAction.reading, - environment: { - .init( - urlClient: $0.urlClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient - ) - } - ) -) diff --git a/EhPanda/View/Detail/DataFlow/TorrentsStore.swift b/EhPanda/View/Detail/DataFlow/TorrentsStore.swift deleted file mode 100644 index b80547f8..00000000 --- a/EhPanda/View/Detail/DataFlow/TorrentsStore.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// TorrentsStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/19. -// - -import Foundation -import TTProgressHUD -import ComposableArchitecture - -struct TorrentsState: Equatable { - enum Route: Equatable { - case hud - case share(URL) - } - struct CancelID: Hashable { - let id = String(describing: TorrentsState.self) - } - - @BindableState var route: Route? - var torrents = [GalleryTorrent]() - var loadingState: LoadingState = .idle - var hudConfig: TTProgressHUDConfig = .copiedToClipboardSucceeded -} - -enum TorrentsAction: BindableAction { - case binding(BindingAction) - case setNavigation(TorrentsState.Route) - - case copyText(String) - case presentTorrentActivity(String, Data) - - case teardown - case fetchTorrent(String, URL) - case fetchTorrentDone(String, Result) - case fetchGalleryTorrents(String, String) - case fetchGalleryTorrentsDone(Result<[GalleryTorrent], AppError>) -} - -struct TorrentsEnvironment { - let fileClient: FileClient - let hapticClient: HapticClient - let clipboardClient: ClipboardClient -} - -let torrentsReducer = Reducer { state, action, environment in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return .none - - case .copyText(let magnetURL): - state.route = .hud - return .merge( - environment.clipboardClient.saveText(magnetURL).fireAndForget(), - environment.hapticClient.generateNotificationFeedback(.success).fireAndForget() - ) - - case .presentTorrentActivity(let hash, let data): - if let url = environment.fileClient.saveTorrent(hash: hash, data: data) { - return .init(value: .setNavigation(.share(url))) - } - return .none - - case .fetchTorrent(let hash, let torrentURL): - return DataRequest(url: torrentURL).effect.map({ TorrentsAction.fetchTorrentDone(hash, $0) }) - .cancellable(id: TorrentsState.CancelID()) - - case .teardown: - return .cancel(id: TorrentsState.CancelID()) - - case .fetchTorrentDone(let hash, let result): - if case .success(let data) = result, !data.isEmpty { - return .init(value: .presentTorrentActivity(hash, data)) - } - return .none - - case .fetchGalleryTorrents(let gid, let token): - guard state.loadingState != .loading else { return .none } - state.loadingState = .loading - return GalleryTorrentsRequest(gid: gid, token: token) - .effect.map(TorrentsAction.fetchGalleryTorrentsDone).cancellable(id: TorrentsState.CancelID()) - - case .fetchGalleryTorrentsDone(let result): - state.loadingState = .idle - switch result { - case .success(let torrents): - guard !torrents.isEmpty else { - state.loadingState = .failed(.notFound) - return .none - } - state.torrents = torrents - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - } -} -.haptics( - unwrapping: \.route, - case: /TorrentsState.Route.share, - hapticClient: \.hapticClient -) -.binding() diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift new file mode 100644 index 00000000..0cb4c5d0 --- /dev/null +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -0,0 +1,420 @@ +// +// DetailReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/10. +// + +import SwiftUI +import Foundation +import ComposableArchitecture + +struct DetailReducer: ReducerProtocol { + enum Route: Equatable { + case reading + case archives(URL, URL) + case torrents + case previews + case comments(URL) + case share(URL) + case postComment + case newDawn(Greeting) + case detailSearch(String) + case tagDetail(TagDetail) + case galleryInfos(Gallery, GalleryDetail) + } + + private enum CancelID: CaseIterable { + case fetchDatabaseInfos, fetchGalleryDetail, rateGallery, favorGallery, unfavorGallery, postComment, voteTag + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var commentContent = "" + @BindingState var postCommentFocused = false + + var showsNewDawnGreeting = false + var showsUserRating = false + var showsFullTitle = false + var userRating = 0 + + var apiKey = "" + var loadingState: LoadingState = .idle + var gallery: Gallery = .empty + var galleryDetail: GalleryDetail? + var galleryTags = [GalleryTag]() + var galleryPreviewURLs = [Int: URL]() + var galleryComments = [GalleryComment]() + + var readingState = ReadingReducer.State() + var archivesState = ArchivesReducer.State() + var torrentsState = TorrentsReducer.State() + var previewsState = PreviewsReducer.State() + @Heap var commentsState: CommentsReducer.State? + var galleryInfosState = GalleryInfosReducer.State() + @Heap var detailSearchState: DetailSearchReducer.State? + + init() { + _commentsState = .init(nil) + _detailSearchState = .init(nil) + } + + mutating func updateRating(value: DragGesture.Value) { + let rating = Int(value.location.x / 31 * 2) + 1 + userRating = min(max(rating, 1), 10) + } + } + + indirect enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case clearSubStates + case onPostCommentAppear + case onAppear(String, Bool) + + case toggleShowFullTitle + case toggleShowUserRating + case setCommentContent(String) + case setPostCommentFocused(Bool) + case updateRating(DragGesture.Value) + case confirmRating(DragGesture.Value) + case confirmRatingDone + + case syncGalleryTags + case syncGalleryDetail + case syncGalleryPreviewURLs + case syncGalleryComments + case syncGreeting(Greeting) + case syncPreviewConfig(PreviewConfig) + case saveGalleryHistory + case updateReadingProgress(Int) + + case teardown + case fetchDatabaseInfos(String) + case fetchDatabaseInfosDone(GalleryState) + case fetchGalleryDetail + case fetchGalleryDetailDone(Result<(GalleryDetail, GalleryState, String, Greeting?), AppError>) + + case rateGallery + case favorGallery(Int) + case unfavorGallery + case postComment(URL) + case voteTag(String, Int) + case anyGalleryOpsDone(Result) + + case reading(ReadingReducer.Action) + case archives(ArchivesReducer.Action) + case torrents(TorrentsReducer.Action) + case previews(PreviewsReducer.Action) + case comments(CommentsReducer.Action) + case galleryInfos(GalleryInfosReducer.Action) + case detailSearch(DetailSearchReducer.Action) + } + + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.cookieClient) private var cookieClient + + var body: some ReducerProtocol { + RecurseReducer { (self) in + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.readingState = .init() + state.archivesState = .init() + state.torrentsState = .init() + state.previewsState = .init() + state.commentsState = .init() + state.commentContent = .init() + state.postCommentFocused = false + state.galleryInfosState = .init() + state.detailSearchState = .init() + return .merge( + .init(value: .reading(.teardown)), + .init(value: .archives(.teardown)), + .init(value: .torrents(.teardown)), + .init(value: .previews(.teardown)), + .init(value: .comments(.teardown)), + .init(value: .detailSearch(.teardown)) + ) + + case .onPostCommentAppear: + return .init(value: .setPostCommentFocused(true)) + .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect() + + case .onAppear(let gid, let showsNewDawnGreeting): + state.showsNewDawnGreeting = showsNewDawnGreeting + if state.detailSearchState == nil { + state.detailSearchState = .init() + } + if state.commentsState == nil { + state.commentsState = .init() + } + return .init(value: .fetchDatabaseInfos(gid)) + + case .toggleShowFullTitle: + state.showsFullTitle.toggle() + return .fireAndForget({ hapticsClient.generateFeedback(.soft) }) + + case .toggleShowUserRating: + state.showsUserRating.toggle() + return .fireAndForget({ hapticsClient.generateFeedback(.soft) }) + + case .setCommentContent(let content): + state.commentContent = content + return .none + + case .setPostCommentFocused(let isFocused): + state.postCommentFocused = isFocused + return .none + + case .updateRating(let value): + state.updateRating(value: value) + return .none + + case .confirmRating(let value): + state.updateRating(value: value) + return .merge( + .init(value: .rateGallery), + .fireAndForget({ hapticsClient.generateFeedback(.soft) }), + .init(value: .confirmRatingDone).delay(for: 1, scheduler: DispatchQueue.main).eraseToEffect() + ) + + case .confirmRatingDone: + state.showsUserRating = false + return .none + + case .syncGalleryTags: + return databaseClient + .updateGalleryTags(gid: state.gallery.id, tags: state.galleryTags).fireAndForget() + + case .syncGalleryDetail: + guard let detail = state.galleryDetail else { return .none } + return databaseClient.cacheGalleryDetail(detail).fireAndForget() + + case .syncGalleryPreviewURLs: + return databaseClient + .updatePreviewURLs(gid: state.gallery.id, previewURLs: state.galleryPreviewURLs).fireAndForget() + + case .syncGalleryComments: + return databaseClient + .updateComments(gid: state.gallery.id, comments: state.galleryComments).fireAndForget() + + case .syncGreeting(let greeting): + return databaseClient.updateGreeting(greeting).fireAndForget() + + case .syncPreviewConfig(let config): + return databaseClient + .updatePreviewConfig(gid: state.gallery.id, config: config).fireAndForget() + + case .saveGalleryHistory: + return databaseClient.updateLastOpenDate(gid: state.gallery.id).fireAndForget() + + case .updateReadingProgress(let progress): + return databaseClient + .updateReadingProgress(gid: state.gallery.id, progress: progress).fireAndForget() + + case .teardown: + return .cancel(ids: CancelID.allCases) + + case .fetchDatabaseInfos(let gid): + guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } + state.gallery = gallery + if let detail = databaseClient.fetchGalleryDetail(gid: gid) { + state.galleryDetail = detail + } + return .merge( + .init(value: .saveGalleryHistory), + databaseClient.fetchGalleryState(gid: state.gallery.id) + .map(Action.fetchDatabaseInfosDone).cancellable(id: CancelID.fetchDatabaseInfos) + ) + + case .fetchDatabaseInfosDone(let galleryState): + state.galleryTags = galleryState.tags + state.galleryPreviewURLs = galleryState.previewURLs + state.galleryComments = galleryState.comments + return .init(value: .fetchGalleryDetail) + + case .fetchGalleryDetail: + guard state.loadingState != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + state.loadingState = .loading + return GalleryDetailRequest(gid: state.gallery.id, galleryURL: galleryURL) + .effect.map(Action.fetchGalleryDetailDone).cancellable(id: CancelID.fetchGalleryDetail) + + case .fetchGalleryDetailDone(let result): + state.loadingState = .idle + switch result { + case .success(let (galleryDetail, galleryState, apiKey, greeting)): + var effects: [EffectTask] = [ + .init(value: .syncGalleryTags), + .init(value: .syncGalleryDetail), + .init(value: .syncGalleryPreviewURLs), + .init(value: .syncGalleryComments) + ] + state.apiKey = apiKey + state.galleryDetail = galleryDetail + state.galleryTags = galleryState.tags + state.galleryPreviewURLs = galleryState.previewURLs + state.galleryComments = galleryState.comments + state.userRating = Int(galleryDetail.userRating) * 2 + if let greeting = greeting { + effects.append(.init(value: .syncGreeting(greeting))) + if !greeting.gainedNothing && state.showsNewDawnGreeting { + effects.append(.init(value: .setNavigation(.newDawn(greeting)))) + } + } + if let config = galleryState.previewConfig { + effects.append(.init(value: .syncPreviewConfig(config))) + } + return .merge(effects) + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .rateGallery: + guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) + else { return .none } + return RateGalleryRequest( + apiuid: apiuid, apikey: state.apiKey, gid: gid, + token: state.gallery.token, rating: state.userRating + ) + .effect.map(Action.anyGalleryOpsDone).cancellable(id: CancelID.rateGallery) + + case .favorGallery(let favIndex): + return FavorGalleryRequest(gid: state.gallery.id, token: state.gallery.token, favIndex: favIndex) + .effect.map(Action.anyGalleryOpsDone).cancellable(id: CancelID.favorGallery) + + case .unfavorGallery: + return UnfavorGalleryRequest(gid: state.gallery.id).effect.map(Action.anyGalleryOpsDone) + .cancellable(id: CancelID.unfavorGallery) + + case .postComment(let galleryURL): + guard !state.commentContent.isEmpty else { return .none } + return CommentGalleryRequest(content: state.commentContent, galleryURL: galleryURL) + .effect.map(Action.anyGalleryOpsDone).cancellable(id: CancelID.postComment) + + case .voteTag(let tag, let vote): + guard let apiuid = Int(cookieClient.apiuid), let gid = Int(state.gallery.id) + else { return .none } + return VoteGalleryTagRequest( + apiuid: apiuid, apikey: state.apiKey, gid: gid, token: state.gallery.token, tag: tag, vote: vote + ) + .effect.map(Action.anyGalleryOpsDone).cancellable(id: CancelID.voteTag) + + case .anyGalleryOpsDone(let result): + if case .success = result { + return .merge( + .init(value: .fetchGalleryDetail), + .fireAndForget({ hapticsClient.generateNotificationFeedback(.success) }) + ) + } + return .fireAndForget({ hapticsClient.generateNotificationFeedback(.error) }) + + case .reading(.onPerformDismiss): + return .init(value: .setNavigation(nil)) + + case .reading: + return .none + + case .archives: + return .none + + case .torrents: + return .none + + case .previews: + return .none + + case .comments(.performCommentActionDone(let result)): + return .init(value: .anyGalleryOpsDone(result)) + + case .comments(.detail(let recursiveAction)): + guard state.commentsState != nil else { return .none } + return self.reduce(into: &state.commentsState!.detailState, action: recursiveAction) + .map({ Action.comments(.detail($0)) }) + + case .comments: + return .none + + case .galleryInfos: + return .none + + case .detailSearch(.detail(let recursiveAction)): + guard state.detailSearchState != nil else { return .none } + return self.reduce(into: &state.detailSearchState!.detailState, action: recursiveAction) + .map({ Action.detailSearch(.detail($0)) }) + + case .detailSearch: + return .none + } + } + } + .ifLet( + \.commentsState, + action: /Action.comments, + then: CommentsReducer.init + ) + .ifLet( + \.detailSearchState, + action: /Action.detailSearch, + then: DetailSearchReducer.init + ) + .haptics( + unwrapping: \.route, + case: /Route.detailSearch, + hapticsClient: hapticsClient, + style: .soft + ) + .haptics( + unwrapping: \.route, + case: /Route.postComment, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.route, + case: /Route.tagDetail, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.route, + case: /Route.torrents, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.route, + case: /Route.archives, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.route, + case: /Route.reading, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.route, + case: /Route.share, + hapticsClient: hapticsClient + ) + + Scope(state: \.readingState, action: /Action.reading, child: ReadingReducer.init) + Scope(state: \.archivesState, action: /Action.archives, child: ArchivesReducer.init) + Scope(state: \.torrentsState, action: /Action.torrents, child: TorrentsReducer.init) + Scope(state: \.previewsState, action: /Action.previews, child: PreviewsReducer.init) + Scope(state: \.galleryInfosState, action: /Action.galleryInfos, child: GalleryInfosReducer.init) + } +} diff --git a/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift b/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift new file mode 100644 index 00000000..d1aa1d7b --- /dev/null +++ b/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift @@ -0,0 +1,187 @@ +// +// DetailSearchReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/12. +// + +import ComposableArchitecture + +struct DetailSearchReducer: ReducerProtocol { + enum Route: Equatable { + case filters + case quickSearch + case detail(String) + } + + private enum CancelID: CaseIterable { + case fetchGalleries, fetchMoreGalleries + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var keyword = "" + var lastKeyword = "" + + var galleries = [Gallery]() + var pageNumber = PageNumber() + var loadingState: LoadingState = .idle + var footerLoadingState: LoadingState = .idle + + @Heap var detailState: DetailReducer.State! + var filtersState = FiltersReducer.State() + var quickDetailSearchState = QuickSearchReducer.State() + + init() { + _detailState = .init(.init()) + } + + mutating func insertGalleries(_ galleries: [Gallery]) { + galleries.forEach { gallery in + if !self.galleries.contains(gallery) { + self.galleries.append(gallery) + } + } + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case clearSubStates + + case teardown + case fetchGalleries(String? = nil) + case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + + case detail(DetailReducer.Action) + case filters(FiltersReducer.Action) + case quickSearch(QuickSearchReducer.Action) + } + + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.hapticsClient) private var hapticsClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding(\.$keyword): + if !state.keyword.isEmpty { + state.lastKeyword = state.keyword + } + return .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.detailState = .init() + state.filtersState = .init() + state.quickDetailSearchState = .init() + return .merge( + .init(value: .detail(.teardown)), + .init(value: .quickSearch(.teardown)) + ) + + case .teardown: + return .cancel(ids: CancelID.allCases) + + case .fetchGalleries(let keyword): + guard state.loadingState != .loading else { return .none } + if let keyword = keyword { + state.keyword = keyword + state.lastKeyword = keyword + } + state.loadingState = .loading + state.pageNumber.resetPages() + let filter = databaseClient.fetchFilterSynchronously(range: .search) + return SearchGalleriesRequest(keyword: state.lastKeyword, filter: filter).effect + .map(Action.fetchGalleriesDone).cancellable(id: CancelID.fetchGalleries) + + case .fetchGalleriesDone(let result): + state.loadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + guard pageNumber.hasNextPage() else { return .none } + return .init(value: .fetchMoreGalleries) + } + state.pageNumber = pageNumber + state.galleries = galleries + return databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber + guard pageNumber.hasNextPage(), + state.footerLoadingState != .loading, + let lastID = state.galleries.last?.id + else { return .none } + state.footerLoadingState = .loading + let filter = databaseClient.fetchFilterSynchronously(range: .search) + return MoreSearchGalleriesRequest(keyword: state.lastKeyword, filter: filter, lastID: lastID).effect + .map(Action.fetchMoreGalleriesDone) + .cancellable(id: CancelID.fetchMoreGalleries) + + case .fetchMoreGalleriesDone(let result): + state.footerLoadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + state.pageNumber = pageNumber + state.insertGalleries(galleries) + + var effects: [EffectTask] = [ + databaseClient.cacheGalleries(galleries).fireAndForget() + ] + if galleries.isEmpty, pageNumber.hasNextPage() { + effects.append(.init(value: .fetchMoreGalleries)) + } else if !galleries.isEmpty { + state.loadingState = .idle + } + return .merge(effects) + + case .failure(let error): + state.footerLoadingState = .failed(error) + } + return .none + + case .detail: + return .none + + case .filters: + return .none + + case .quickSearch: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /Route.quickSearch, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.route, + case: /Route.filters, + hapticsClient: hapticsClient + ) + + Scope(state: \.filtersState, action: /Action.filters, child: FiltersReducer.init) + Scope(state: \.quickDetailSearchState, action: /Action.quickSearch, child: QuickSearchReducer.init) + } +} diff --git a/EhPanda/View/Detail/DetailSearchView.swift b/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift similarity index 79% rename from EhPanda/View/Detail/DetailSearchView.swift rename to EhPanda/View/Detail/DetailSearch/DetailSearchView.swift index 8460d664..f8567c73 100644 --- a/EhPanda/View/Detail/DetailSearchView.swift +++ b/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift @@ -9,8 +9,8 @@ import SwiftUI import ComposableArchitecture struct DetailSearchView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let keyword: String private let user: User @Binding private var setting: Setting @@ -18,7 +18,7 @@ struct DetailSearchView: View { private let tagTranslator: TagTranslator init( - store: Store, + store: StoreOf, keyword: String, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store @@ -46,21 +46,21 @@ struct DetailSearchView: View { ) .sheet( unwrapping: viewStore.binding(\.$route), - case: /DetailSearchState.Route.detail, + case: /DetailSearchReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in NavigationView { DetailView( - store: store.scope(state: \.detailState, action: DetailSearchAction.detail), + store: store.scope(state: \.detailState, action: DetailSearchReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) } .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailSearchState.Route.quickSearch) { _ in + .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailSearchReducer.Route.quickSearch) { _ in QuickSearchView( - store: store.scope(state: \.quickDetailSearchState, action: DetailSearchAction.quickSearch) + store: store.scope(state: \.quickDetailSearchState, action: DetailSearchReducer.Action.quickSearch) ) { keyword in viewStore.send(.setNavigation(nil)) viewStore.send(.fetchGalleries(keyword)) @@ -68,11 +68,12 @@ struct DetailSearchView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailSearchState.Route.filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: DetailSearchAction.filters)) + .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailSearchReducer.Route.filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: DetailSearchReducer.Action.filters)) .accentColor(setting.accentColor).autoBlur(radius: blurRadius) } - .searchable(text: viewStore.binding(\.$keyword)) { + .searchable(text: viewStore.binding(\.$keyword)) + .searchSuggestions { TagSuggestionView( keyword: viewStore.binding(\.$keyword), translations: tagTranslator.translations, showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion @@ -95,9 +96,9 @@ struct DetailSearchView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailSearchState.Route.detail) { route in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailSearchReducer.Route.detail) { route in DetailView( - store: store.scope(state: \.detailState, action: DetailSearchAction.detail), + store: store.scope(state: \.detailState, action: DetailSearchReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) @@ -123,19 +124,7 @@ struct DetailSearchView_Previews: PreviewProvider { DetailSearchView( store: .init( initialState: .init(), - reducer: detailSearchReducer, - environment: DetailSearchEnvironment( - urlClient: .live, - fileClient: .live, - imageClient: .live, - deviceClient: .live, - hapticClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live, - uiApplicationClient: .live - ) + reducer: DetailSearchReducer() ), keyword: .init(), user: .init(), diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index 69b09477..a5946b28 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -11,8 +11,8 @@ import ComposableArchitecture import CommonMark struct DetailView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let gid: String private let user: User @Binding private var setting: Setting @@ -20,7 +20,7 @@ struct DetailView: View { private let tagTranslator: TagTranslator init( - store: Store, gid: String, + store: StoreOf, gid: String, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store @@ -117,40 +117,40 @@ struct DetailView: View { && viewStore.loadingState == .loading ? 1 : 0 ) let error = (/LoadingState.failed).extract(from: viewStore.loadingState) - let retryAction = { viewStore.send(.fetchGalleryDetail) } + let retryAction: () -> Void = { viewStore.send(.fetchGalleryDetail) } ErrorView(error: error ?? .unknown, action: error?.isRetryable != false ? retryAction : nil) .opacity(viewStore.galleryDetail == nil && error != nil ? 1 : 0) } - .fullScreenCover(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.reading) { _ in + .fullScreenCover(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.reading) { _ in ReadingView( - store: store.scope(state: \.readingState, action: DetailAction.reading), + store: store.scope(state: \.readingState, action: DetailReducer.Action.reading), gid: gid, setting: $setting, blurRadius: blurRadius ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.archives) { route in + .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.archives) { route in let (galleryURL, archiveURL) = route.wrappedValue ArchivesView( - store: store.scope(state: \.archivesState, action: DetailAction.archives), + store: store.scope(state: \.archivesState, action: DetailReducer.Action.archives), gid: gid, user: user, galleryURL: galleryURL, archiveURL: archiveURL ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.torrents) { _ in + .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.torrents) { _ in TorrentsView( - store: store.scope(state: \.torrentsState, action: DetailAction.torrents), + store: store.scope(state: \.torrentsState, action: DetailReducer.Action.torrents), gid: gid, token: viewStore.gallery.token, blurRadius: blurRadius ) .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.share) { route in + .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.share) { route in ActivityView(activityItems: [route.wrappedValue]) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.postComment) { _ in + .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.postComment) { _ in PostCommentView( title: L10n.Localizable.PostCommentView.Title.postComment, content: viewStore.binding(\.$commentContent), @@ -167,10 +167,10 @@ struct DetailView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.newDawn) { route in + .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.newDawn) { route in NewDawnView(greeting: route.wrappedValue).autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.tagDetail) { route in + .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.tagDetail) { route in TagDetailView(detail: route.wrappedValue).autoBlur(radius: blurRadius) } .animation(.default, value: viewStore.showsUserRating) @@ -189,14 +189,14 @@ struct DetailView: View { // MARK: NavigationLinks private extension DetailView { @ViewBuilder var navigationLinks: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.previews) { _ in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.previews) { _ in PreviewsView( - store: store.scope(state: \.previewsState, action: DetailAction.previews), + store: store.scope(state: \.previewsState, action: DetailReducer.Action.previews), gid: gid, setting: $setting, blurRadius: blurRadius ) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.comments) { route in - IfLetStore(store.scope(state: \.commentsState, action: DetailAction.comments)) { store in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.comments) { route in + IfLetStore(store.scope(state: \.commentsState, action: DetailReducer.Action.comments)) { store in CommentsView( store: store, gid: gid, token: viewStore.gallery.token, apiKey: viewStore.apiKey, galleryURL: route.wrappedValue, comments: viewStore.galleryComments, user: user, @@ -205,18 +205,18 @@ private extension DetailView { ) } } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.detailSearch) { route in - IfLetStore(store.scope(state: \.detailSearchState, action: DetailAction.detailSearch)) { store in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.detailSearch) { route in + IfLetStore(store.scope(state: \.detailSearchState, action: DetailReducer.Action.detailSearch)) { store in DetailSearchView( store: store, keyword: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) } } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailState.Route.galleryInfos) { route in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.galleryInfos) { route in let (gallery, galleryDetail) = route.wrappedValue GalleryInfosView( - store: store.scope(state: \.galleryInfosState, action: DetailAction.galleryInfos), + store: store.scope(state: \.galleryInfosState, action: DetailReducer.Action.galleryInfos), gallery: gallery, galleryDetail: galleryDetail ) } @@ -237,7 +237,7 @@ private extension DetailView { } label: { Label(L10n.Localizable.DetailView.ToolbarItem.Button.archives, systemSymbol: .docZipper) } - .disabled(viewStore.galleryDetail?.archiveURL == nil || !CookiesUtil.didLogin) + .disabled(viewStore.galleryDetail?.archiveURL == nil || !CookieUtil.didLogin) Button { viewStore.send(.setNavigation(.torrents)) } label: { @@ -342,7 +342,7 @@ private struct HeaderSection: View { .opacity(galleryDetail.isFavorited ? 0 : 1) } .imageScale(.large).foregroundStyle(.tint) - .disabled(!CookiesUtil.didLogin) + .disabled(!CookieUtil.didLogin) Button(action: navigateReadingAction) { Text(L10n.Localizable.DetailView.Button.read) .bold().textCase(.uppercase).font(.headline) @@ -516,7 +516,7 @@ private struct ActionSection: View { Text(L10n.Localizable.DetailView.ActionSection.Button.giveARating).bold() Spacer() } - .disabled(!CookiesUtil.didLogin) + .disabled(!CookieUtil.didLogin) Button(action: navigateSimilarGalleryAction) { Spacer() Image(systemSymbol: .photoOnRectangleAngled) @@ -655,7 +655,7 @@ private extension TagsSection { Text(L10n.Localizable.DetailView.ContextMenu.Button.detail) } } - if CookiesUtil.didLogin { + if CookieUtil.didLogin { if content.isVotedUp || content.isVotedDown { Button { voteTagAction(content.voteKeyword(tag: tag), content.isVotedUp ? -1 : 1) @@ -774,7 +774,7 @@ private struct CommentsSection: View { .drawingGroup() } CommentButton(backgroundColor: backgroundColor, action: navigatePostCommentAction) - .padding(.horizontal).disabled(!CookiesUtil.didLogin) + .padding(.horizontal).disabled(!CookieUtil.didLogin) } } } @@ -849,19 +849,7 @@ struct DetailView_Previews: PreviewProvider { DetailView( store: .init( initialState: .init(), - reducer: detailReducer, - environment: DetailEnvironment( - urlClient: .live, - fileClient: .live, - imageClient: .live, - deviceClient: .live, - hapticClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live, - uiApplicationClient: .live - ) + reducer: DetailReducer() ), gid: .init(), user: .init(), diff --git a/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift b/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift new file mode 100644 index 00000000..9433f90b --- /dev/null +++ b/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift @@ -0,0 +1,46 @@ +// +// GalleryInfosReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/23. +// + +import TTProgressHUD +import ComposableArchitecture + +struct GalleryInfosReducer: ReducerProtocol { + enum Route { + case hud + } + + struct State: Equatable { + @BindingState var route: Route? + var hudConfig: TTProgressHUDConfig = .copiedToClipboardSucceeded + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case copyText(String) + } + + @Dependency(\.clipboardClient) private var clipboardClient + @Dependency(\.hapticsClient) private var hapticsClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .copyText(let text): + state.route = .hud + return .merge( + clipboardClient.saveText(text).fireAndForget(), + .fireAndForget({ hapticsClient.generateNotificationFeedback(.success) }) + ) + } + } + } +} diff --git a/EhPanda/View/Detail/GalleryInfosView.swift b/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift similarity index 90% rename from EhPanda/View/Detail/GalleryInfosView.swift rename to EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift index 1157b3dd..09500be6 100644 --- a/EhPanda/View/Detail/GalleryInfosView.swift +++ b/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift @@ -9,12 +9,12 @@ import SwiftUI import ComposableArchitecture struct GalleryInfosView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let gallery: Gallery private let galleryDetail: GalleryDetail - init(store: Store, gallery: Gallery, galleryDetail: GalleryDetail) { + init(store: StoreOf, gallery: Gallery, galleryDetail: GalleryDetail) { self.store = store viewStore = ViewStore(store) self.gallery = gallery @@ -119,7 +119,7 @@ struct GalleryInfosView: View { .progressHUD( config: viewStore.hudConfig, unwrapping: viewStore.binding(\.$route), - case: /GalleryInfosState.Route.hud + case: /GalleryInfosReducer.Route.hud ) .navigationTitle(L10n.Localizable.GalleryInfosView.Title.galleryInfos) } @@ -137,11 +137,7 @@ struct GalleryInfosView_Previews: PreviewProvider { GalleryInfosView( store: .init( initialState: .init(), - reducer: galleryInfosReducer, - environment: GalleryInfosEnvironment( - hapticClient: .live, - clipboardClient: .live - ) + reducer: GalleryInfosReducer() ), gallery: .preview, galleryDetail: .preview diff --git a/EhPanda/View/Detail/Previews/PreviewsReducer.swift b/EhPanda/View/Detail/Previews/PreviewsReducer.swift new file mode 100644 index 00000000..ec25a37b --- /dev/null +++ b/EhPanda/View/Detail/Previews/PreviewsReducer.swift @@ -0,0 +1,143 @@ +// +// PreviewsReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/16. +// + +import Foundation +import ComposableArchitecture + +struct PreviewsReducer: ReducerProtocol { + enum Route { + case reading + } + + private enum CancelID: CaseIterable { + case fetchDatabaseInfos, fetchPreviewURLs + } + + struct State: Equatable { + @BindingState var route: Route? + + var gallery: Gallery = .empty + var loadingState: LoadingState = .idle + var databaseLoadingState: LoadingState = .loading + + var previewURLs = [Int: URL]() + var previewConfig: PreviewConfig = .normal(rows: 4) + + var readingState = ReadingReducer.State() + + mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { + self.previewURLs = self.previewURLs.merging( + previewURLs, uniquingKeysWith: { stored, _ in stored } + ) + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case clearSubStates + + case syncPreviewURLs([Int: URL]) + case updateReadingProgress(Int) + + case teardown + case fetchDatabaseInfos(String) + case fetchDatabaseInfosDone(GalleryState) + case fetchPreviewURLs(Int) + case fetchPreviewURLsDone(Result<[Int: URL], AppError>) + + case reading(ReadingReducer.Action) + } + + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.hapticsClient) private var hapticsClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.readingState = .init() + return .init(value: .reading(.teardown)) + + case .syncPreviewURLs(let previewURLs): + return databaseClient + .updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs).fireAndForget() + + case .updateReadingProgress(let progress): + return databaseClient + .updateReadingProgress(gid: state.gallery.id, progress: progress).fireAndForget() + + case .teardown: + return .cancel(ids: CancelID.allCases) + + case .fetchDatabaseInfos(let gid): + guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } + state.gallery = gallery + return databaseClient.fetchGalleryState(gid: state.gallery.id) + .map(Action.fetchDatabaseInfosDone).cancellable(id: CancelID.fetchDatabaseInfos) + + case .fetchDatabaseInfosDone(let galleryState): + if let previewConfig = galleryState.previewConfig { + state.previewConfig = previewConfig + } + state.previewURLs = galleryState.previewURLs + state.databaseLoadingState = .idle + return .none + + case .fetchPreviewURLs(let index): + guard state.loadingState != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + state.loadingState = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + return GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum) + .effect.map(Action.fetchPreviewURLsDone).cancellable(id: CancelID.fetchPreviewURLs) + + case .fetchPreviewURLsDone(let result): + state.loadingState = .idle + + switch result { + case .success(let previewURLs): + guard !previewURLs.isEmpty else { + state.loadingState = .failed(.notFound) + return .none + } + state.updatePreviewURLs(previewURLs) + return .init(value: .syncPreviewURLs(previewURLs)) + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .reading(.onPerformDismiss): + return .init(value: .setNavigation(nil)) + + case .reading: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /Route.reading, + hapticsClient: hapticsClient + ) + + Scope(state: \.readingState, action: /Action.reading, child: ReadingReducer.init) + } +} diff --git a/EhPanda/View/Detail/PreviewsView.swift b/EhPanda/View/Detail/Previews/PreviewsView.swift similarity index 81% rename from EhPanda/View/Detail/PreviewsView.swift rename to EhPanda/View/Detail/Previews/PreviewsView.swift index 6139b3d0..1a255c68 100644 --- a/EhPanda/View/Detail/PreviewsView.swift +++ b/EhPanda/View/Detail/Previews/PreviewsView.swift @@ -10,14 +10,14 @@ import Kingfisher import ComposableArchitecture struct PreviewsView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let gid: String @Binding private var setting: Setting private let blurRadius: Double init( - store: Store, + store: StoreOf, gid: String, setting: Binding, blurRadius: Double ) { self.store = store @@ -68,9 +68,9 @@ struct PreviewsView: View { .padding(.bottom) .id(viewStore.databaseLoadingState) } - .fullScreenCover(unwrapping: viewStore.binding(\.$route), case: /PreviewsState.Route.reading) { _ in + .fullScreenCover(unwrapping: viewStore.binding(\.$route), case: /PreviewsReducer.Route.reading) { _ in ReadingView( - store: store.scope(state: \.readingState, action: PreviewsAction.reading), + store: store.scope(state: \.readingState, action: PreviewsReducer.Action.reading), gid: gid, setting: $setting, blurRadius: blurRadius ) .accentColor(setting.accentColor) @@ -89,17 +89,7 @@ struct PreviewsView_Previews: PreviewProvider { PreviewsView( store: .init( initialState: .init(gallery: .preview), - reducer: previewsReducer, - environment: PreviewsEnvironment( - urlClient: .live, - imageClient: .live, - deviceClient: .live, - hapticClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live - ) + reducer: PreviewsReducer() ), gid: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Detail/Torrents/TorrentsReducer.swift b/EhPanda/View/Detail/Torrents/TorrentsReducer.swift new file mode 100644 index 00000000..24e2d304 --- /dev/null +++ b/EhPanda/View/Detail/Torrents/TorrentsReducer.swift @@ -0,0 +1,112 @@ +// +// TorrentsReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/19. +// + +import Foundation +import TTProgressHUD +import ComposableArchitecture + +struct TorrentsReducer: ReducerProtocol { + enum Route: Equatable { + case hud + case share(URL) + } + + private enum CancelID: CaseIterable { + case fetchTorrent, fetchGalleryTorrents + } + + struct State: Equatable { + @BindingState var route: Route? + var torrents = [GalleryTorrent]() + var loadingState: LoadingState = .idle + var hudConfig: TTProgressHUDConfig = .copiedToClipboardSucceeded + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case setNavigation(Route) + + case copyText(String) + case presentTorrentActivity(String, Data) + + case teardown + case fetchTorrent(String, URL) + case fetchTorrentDone(String, Result) + case fetchGalleryTorrents(String, String) + case fetchGalleryTorrentsDone(Result<[GalleryTorrent], AppError>) + } + + @Dependency(\.clipboardClient) private var clipboardClient + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.fileClient) private var fileClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .copyText(let magnetURL): + state.route = .hud + return .merge( + clipboardClient.saveText(magnetURL).fireAndForget(), + .fireAndForget({ hapticsClient.generateNotificationFeedback(.success) }) + ) + + case .presentTorrentActivity(let hash, let data): + if let url = fileClient.saveTorrent(hash: hash, data: data) { + return .init(value: .setNavigation(.share(url))) + } + return .none + + case .fetchTorrent(let hash, let torrentURL): + return DataRequest(url: torrentURL).effect.map({ Action.fetchTorrentDone(hash, $0) }) + .cancellable(id: CancelID.fetchTorrent) + + case .teardown: + return .cancel(ids: CancelID.allCases) + + case .fetchTorrentDone(let hash, let result): + if case .success(let data) = result, !data.isEmpty { + return .init(value: .presentTorrentActivity(hash, data)) + } + return .none + + case .fetchGalleryTorrents(let gid, let token): + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + return GalleryTorrentsRequest(gid: gid, token: token) + .effect.map(Action.fetchGalleryTorrentsDone).cancellable(id: CancelID.fetchGalleryTorrents) + + case .fetchGalleryTorrentsDone(let result): + state.loadingState = .idle + switch result { + case .success(let torrents): + guard !torrents.isEmpty else { + state.loadingState = .failed(.notFound) + return .none + } + state.torrents = torrents + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + } + } + .haptics( + unwrapping: \.route, + case: /Route.share, + hapticsClient: hapticsClient + ) + } +} diff --git a/EhPanda/View/Detail/TorrentsView.swift b/EhPanda/View/Detail/Torrents/TorrentsView.swift similarity index 87% rename from EhPanda/View/Detail/TorrentsView.swift rename to EhPanda/View/Detail/Torrents/TorrentsView.swift index 97ef8b04..5a8e1dc3 100644 --- a/EhPanda/View/Detail/TorrentsView.swift +++ b/EhPanda/View/Detail/Torrents/TorrentsView.swift @@ -9,13 +9,13 @@ import SwiftUI import ComposableArchitecture struct TorrentsView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let gid: String private let token: String private let blurRadius: Double - init(store: Store, gid: String, token: String, blurRadius: Double) { + init(store: StoreOf, gid: String, token: String, blurRadius: Double) { self.store = store viewStore = ViewStore(store) self.gid = gid @@ -45,14 +45,14 @@ struct TorrentsView: View { } .opacity(error != nil && viewStore.torrents.isEmpty ? 1 : 0) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /TorrentsState.Route.share) { route in + .sheet(unwrapping: viewStore.binding(\.$route), case: /TorrentsReducer.Route.share) { route in ActivityView(activityItems: [route.wrappedValue]) .autoBlur(radius: blurRadius) } .progressHUD( config: viewStore.hudConfig, unwrapping: viewStore.binding(\.$route), - case: /TorrentsState.Route.hud + case: /TorrentsReducer.Route.hud ) .animation(.default, value: viewStore.torrents) .onAppear { @@ -120,12 +120,7 @@ struct TorrentsView_Previews: PreviewProvider { TorrentsView( store: .init( initialState: .init(), - reducer: torrentsReducer, - environment: TorrentsEnvironment( - fileClient: .live, - hapticClient: .live, - clipboardClient: .live - ) + reducer: TorrentsReducer() ), gid: .init(), token: .init(), diff --git a/EhPanda/View/Favorites/FavoritesReducer.swift b/EhPanda/View/Favorites/FavoritesReducer.swift new file mode 100644 index 00000000..c2c694cf --- /dev/null +++ b/EhPanda/View/Favorites/FavoritesReducer.swift @@ -0,0 +1,194 @@ +// +// FavoritesReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/29. +// + +import SwiftUI +import IdentifiedCollections +import ComposableArchitecture + +struct FavoritesReducer: ReducerProtocol { + enum Route: Equatable { + case quickSearch + case detail(String) + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var keyword = "" + + var index = -1 + var sortOrder: FavoritesSortOrder? + + var rawGalleries = [Int: [Gallery]]() + var rawPageNumber = [Int: PageNumber]() + var rawLoadingState = [Int: LoadingState]() + var rawFooterLoadingState = [Int: LoadingState]() + + var galleries: [Gallery]? { + rawGalleries[index] + } + var pageNumber: PageNumber? { + rawPageNumber[index] + } + var loadingState: LoadingState? { + rawLoadingState[index] + } + var footerLoadingState: LoadingState? { + rawFooterLoadingState[index] + } + + @Heap var detailState: DetailReducer.State! + var quickSearchState = QuickSearchReducer.State() + + init() { + _detailState = .init(.init()) + } + + mutating func insertGalleries(index: Int, galleries: [Gallery]) { + galleries.forEach { gallery in + if rawGalleries[index]?.contains(gallery) == false { + rawGalleries[index]?.append(gallery) + } + } + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case setFavoritesIndex(Int) + case clearSubStates + case onNotLoginViewButtonTapped + + case fetchGalleries(String? = nil, FavoritesSortOrder? = nil) + case fetchGalleriesDone(Int, Result<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError>) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Int, Result<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError>) + + case detail(DetailReducer.Action) + case quickSearch(QuickSearchReducer.Action) + } + + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.hapticsClient) private var hapticsClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .setFavoritesIndex(let index): + state.index = index + guard state.galleries?.isEmpty != false else { return .none } + return .init(value: Action.fetchGalleries()) + + case .clearSubStates: + state.detailState = .init() + return .init(value: .detail(.teardown)) + + case .onNotLoginViewButtonTapped: + return .none + + case .fetchGalleries(let keyword, let sortOrder): + guard state.loadingState != .loading else { return .none } + state.rawLoadingState[state.index] = .loading + if let keyword = keyword { + state.keyword = keyword + } + if state.pageNumber == nil { + state.rawPageNumber[state.index] = PageNumber() + } else { + state.rawPageNumber[state.index]?.resetPages() + } + return FavoritesGalleriesRequest( + favIndex: state.index, keyword: state.keyword, sortOrder: sortOrder + ) + .effect.map { [index = state.index] result in Action.fetchGalleriesDone(index, result) } + + case .fetchGalleriesDone(let targetFavIndex, let result): + state.rawLoadingState[targetFavIndex] = .idle + switch result { + case .success(let (pageNumber, sortOrder, galleries)): + guard !galleries.isEmpty else { + state.rawLoadingState[targetFavIndex] = .failed(.notFound) + guard pageNumber.hasNextPage() else { return .none } + return .init(value: .fetchMoreGalleries) + } + state.rawPageNumber[targetFavIndex] = pageNumber + state.rawGalleries[targetFavIndex] = galleries + state.sortOrder = sortOrder + return databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.rawLoadingState[targetFavIndex] = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber ?? .init() + guard pageNumber.hasNextPage(), + state.footerLoadingState != .loading, + let lastID = state.galleries?.last?.id, + let lastItemTimestamp = pageNumber.lastItemTimestamp + else { return .none } + state.rawFooterLoadingState[state.index] = .loading + return MoreFavoritesGalleriesRequest( + favIndex: state.index, + lastID: lastID, + lastTimestamp: lastItemTimestamp, + keyword: state.keyword + ) + .effect.map { [index = state.index] result in Action.fetchMoreGalleriesDone(index, result) } + + case .fetchMoreGalleriesDone(let targetFavIndex, let result): + state.rawFooterLoadingState[targetFavIndex] = .idle + switch result { + case .success(let (pageNumber, sortOrder, galleries)): + state.rawPageNumber[targetFavIndex] = pageNumber + state.insertGalleries(index: targetFavIndex, galleries: galleries) + state.sortOrder = sortOrder + + var effects: [EffectTask] = [ + databaseClient.cacheGalleries(galleries).fireAndForget() + ] + if galleries.isEmpty, pageNumber.hasNextPage() { + effects.append(.init(value: .fetchMoreGalleries)) + } else if !galleries.isEmpty { + state.rawLoadingState[targetFavIndex] = .idle + } + return .merge(effects) + + case .failure(let error): + state.rawFooterLoadingState[targetFavIndex] = .failed(error) + } + return .none + + case .detail: + return .none + + case .quickSearch: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /Route.quickSearch, + hapticsClient: hapticsClient + ) + + Scope(state: \.detailState, action: /Action.detail, child: DetailReducer.init) + Scope(state: \.quickSearchState, action: /Action.quickSearch, child: QuickSearchReducer.init) + } +} diff --git a/EhPanda/View/Favorites/FavoritesStore.swift b/EhPanda/View/Favorites/FavoritesStore.swift deleted file mode 100644 index e7f5d89e..00000000 --- a/EhPanda/View/Favorites/FavoritesStore.swift +++ /dev/null @@ -1,230 +0,0 @@ -// -// FavoritesStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/12/29. -// - -import SwiftUI -import IdentifiedCollections -import ComposableArchitecture - -// MARK: State -struct FavoritesState: Equatable { - enum Route: Equatable { - case quickSearch - case detail(String) - } - - init() { - _detailState = .init(.init()) - } - - @BindableState var route: Route? - @BindableState var keyword = "" - - var index = -1 - var sortOrder: FavoritesSortOrder? - - var rawGalleries = [Int: [Gallery]]() - var rawPageNumber = [Int: PageNumber]() - var rawLoadingState = [Int: LoadingState]() - var rawFooterLoadingState = [Int: LoadingState]() - - var galleries: [Gallery]? { - rawGalleries[index] - } - var pageNumber: PageNumber? { - rawPageNumber[index] - } - var loadingState: LoadingState? { - rawLoadingState[index] - } - var footerLoadingState: LoadingState? { - rawFooterLoadingState[index] - } - - @Heap var detailState: DetailState! - var quickSearchState = QuickSearchState() - - mutating func insertGalleries(index: Int, galleries: [Gallery]) { - galleries.forEach { gallery in - if rawGalleries[index]?.contains(gallery) == false { - rawGalleries[index]?.append(gallery) - } - } - } -} - -// MARK: Action -enum FavoritesAction: BindableAction { - case binding(BindingAction) - case setNavigation(FavoritesState.Route?) - case setFavoritesIndex(Int) - case clearSubStates - case onNotLoginViewButtonTapped - - case fetchGalleries(String? = nil, FavoritesSortOrder? = nil) - case fetchGalleriesDone(Int, Result<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError>) - case fetchMoreGalleries - case fetchMoreGalleriesDone(Int, Result<(PageNumber, FavoritesSortOrder?, [Gallery]), AppError>) - - case detail(DetailAction) - case quickSearch(QuickSearchAction) -} - -// MARK: Environment -struct FavoritesEnvironment { - let urlClient: URLClient - let fileClient: FileClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient - let userDefaultsClient: UserDefaultsClient - let uiApplicationClient: UIApplicationClient -} - -// MARK: Reducer -let favoritesReducer = Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .setFavoritesIndex(let index): - state.index = index - guard state.galleries?.isEmpty != false else { return .none } - return .init(value: FavoritesAction.fetchGalleries()) - - case .clearSubStates: - state.detailState = .init() - return .init(value: .detail(.teardown)) - - case .onNotLoginViewButtonTapped: - return .none - - case .fetchGalleries(let keyword, let sortOrder): - guard state.loadingState != .loading else { return .none } - state.rawLoadingState[state.index] = .loading - if let keyword = keyword { - state.keyword = keyword - } - if state.pageNumber == nil { - state.rawPageNumber[state.index] = PageNumber() - } else { - state.rawPageNumber[state.index]?.resetPages() - } - return FavoritesGalleriesRequest( - favIndex: state.index, keyword: state.keyword, sortOrder: sortOrder - ) - .effect.map { [index = state.index] result in FavoritesAction.fetchGalleriesDone(index, result) } - - case .fetchGalleriesDone(let targetFavIndex, let result): - state.rawLoadingState[targetFavIndex] = .idle - switch result { - case .success(let (pageNumber, sortOrder, galleries)): - guard !galleries.isEmpty else { - state.rawLoadingState[targetFavIndex] = .failed(.notFound) - guard pageNumber.hasNextPage() else { return .none } - return .init(value: .fetchMoreGalleries) - } - state.rawPageNumber[targetFavIndex] = pageNumber - state.rawGalleries[targetFavIndex] = galleries - state.sortOrder = sortOrder - return environment.databaseClient.cacheGalleries(galleries).fireAndForget() - case .failure(let error): - state.rawLoadingState[targetFavIndex] = .failed(error) - } - return .none - - case .fetchMoreGalleries: - let pageNumber = state.pageNumber ?? .init() - guard pageNumber.hasNextPage(), - state.footerLoadingState != .loading, - let lastID = state.galleries?.last?.id, - let lastItemTimestamp = pageNumber.lastItemTimestamp - else { return .none } - state.rawFooterLoadingState[state.index] = .loading - return MoreFavoritesGalleriesRequest( - favIndex: state.index, - lastID: lastID, - lastTimestamp: lastItemTimestamp, - keyword: state.keyword - ) - .effect.map { [index = state.index] result in FavoritesAction.fetchMoreGalleriesDone(index, result) } - - case .fetchMoreGalleriesDone(let targetFavIndex, let result): - state.rawFooterLoadingState[targetFavIndex] = .idle - switch result { - case .success(let (pageNumber, sortOrder, galleries)): - state.rawPageNumber[targetFavIndex] = pageNumber - state.insertGalleries(index: targetFavIndex, galleries: galleries) - state.sortOrder = sortOrder - - var effects: [Effect] = [ - environment.databaseClient.cacheGalleries(galleries).fireAndForget() - ] - if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.init(value: .fetchMoreGalleries)) - } else if !galleries.isEmpty { - state.rawLoadingState[targetFavIndex] = .idle - } - return .merge(effects) - - case .failure(let error): - state.rawFooterLoadingState[targetFavIndex] = .failed(error) - } - return .none - - case .detail: - return .none - - case .quickSearch: - return .none - } - } - .haptics( - unwrapping: \.route, - case: /FavoritesState.Route.quickSearch, - hapticClient: \.hapticClient - ) - .binding(), - detailReducer.pullback( - state: \FavoritesState.detailState, - action: /FavoritesAction.detail, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ), - quickSearchReducer.pullback( - state: \.quickSearchState, - action: /FavoritesAction.quickSearch, - environment: { - .init( - databaseClient: $0.databaseClient - ) - } - ) -) diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift index 8812dd04..fea27eea 100644 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -10,15 +10,15 @@ import AlertKit import ComposableArchitecture struct FavoritesView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let user: User @Binding private var setting: Setting private let blurRadius: Double private let tagTranslator: TagTranslator init( - store: Store, + store: StoreOf, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store @@ -37,7 +37,7 @@ struct FavoritesView: View { var body: some View { NavigationView { ZStack { - if CookiesUtil.didLogin { + if CookieUtil.didLogin { GenericList( galleries: viewStore.galleries ?? [], setting: setting, @@ -57,21 +57,21 @@ struct FavoritesView: View { } .sheet( unwrapping: viewStore.binding(\.$route), - case: /FavoritesState.Route.detail, + case: /FavoritesReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in NavigationView { DetailView( - store: store.scope(state: \.detailState, action: FavoritesAction.detail), + store: store.scope(state: \.detailState, action: FavoritesReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) } .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /FavoritesState.Route.quickSearch) { _ in + .sheet(unwrapping: viewStore.binding(\.$route), case: /FavoritesReducer.Route.quickSearch) { _ in QuickSearchView( - store: store.scope(state: \.quickSearchState, action: FavoritesAction.quickSearch) + store: store.scope(state: \.quickSearchState, action: FavoritesReducer.Action.quickSearch) ) { keyword in viewStore.send(.setNavigation(nil)) viewStore.send(.fetchGalleries(keyword)) @@ -79,7 +79,8 @@ struct FavoritesView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .searchable(text: viewStore.binding(\.$keyword)) { + .searchable(text: viewStore.binding(\.$keyword)) + .searchSuggestions { TagSuggestionView( keyword: viewStore.binding(\.$keyword), translations: tagTranslator.translations, showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion @@ -89,7 +90,7 @@ struct FavoritesView: View { viewStore.send(.fetchGalleries()) } .onAppear { - if viewStore.galleries?.isEmpty != false && CookiesUtil.didLogin { + if viewStore.galleries?.isEmpty != false && CookieUtil.didLogin { DispatchQueue.main.async { viewStore.send(.fetchGalleries()) } @@ -103,9 +104,9 @@ struct FavoritesView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /FavoritesState.Route.detail) { route in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /FavoritesReducer.Route.detail) { route in DetailView( - store: store.scope(state: \.detailState, action: FavoritesAction.detail), + store: store.scope(state: \.detailState, action: FavoritesReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) @@ -136,20 +137,7 @@ struct FavoritesView_Previews: PreviewProvider { FavoritesView( store: .init( initialState: .init(), - reducer: favoritesReducer, - environment: FavoritesEnvironment( - urlClient: .live, - fileClient: .live, - imageClient: .live, - deviceClient: .live, - hapticClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live, - userDefaultsClient: .live, - uiApplicationClient: .live - ) + reducer: FavoritesReducer() ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Home/DataFlow/FrontpageStore.swift b/EhPanda/View/Home/DataFlow/FrontpageStore.swift deleted file mode 100644 index de934c37..00000000 --- a/EhPanda/View/Home/DataFlow/FrontpageStore.swift +++ /dev/null @@ -1,196 +0,0 @@ -// -// FrontpageStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/08. -// - -import ComposableArchitecture - -struct FrontpageState: Equatable { - enum Route: Equatable { - case filters - case detail(String) - } - struct CancelID: Hashable { - let id = String(describing: FrontpageState.self) - } - - init() { - _detailState = .init(.init()) - } - - @BindableState var route: Route? - @BindableState var keyword = "" - - var filteredGalleries: [Gallery] { - guard !keyword.isEmpty else { return galleries } - return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) - } - var galleries = [Gallery]() - var pageNumber = PageNumber() - var loadingState: LoadingState = .idle - var footerLoadingState: LoadingState = .idle - - var filtersState = FiltersState() - @Heap var detailState: DetailState! - - mutating func insertGalleries(_ galleries: [Gallery]) { - galleries.forEach { gallery in - if !self.galleries.contains(gallery) { - self.galleries.append(gallery) - } - } - } -} - -enum FrontpageAction: BindableAction { - case binding(BindingAction) - case setNavigation(FrontpageState.Route?) - case clearSubStates - - case teardown - case fetchGalleries - case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - case fetchMoreGalleries - case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - - case filters(FiltersAction) - case detail(DetailAction) -} - -struct FrontpageEnvironment { - let urlClient: URLClient - let fileClient: FileClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient - let uiApplicationClient: UIApplicationClient -} - -let frontpageReducer = Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .clearSubStates: - state.detailState = .init() - state.filtersState = .init() - return .init(value: .detail(.teardown)) - - case .teardown: - return .cancel(id: FrontpageState.CancelID()) - - case .fetchGalleries: - guard state.loadingState != .loading else { return .none } - state.loadingState = .loading - state.pageNumber.resetPages() - let filter = environment.databaseClient.fetchFilterSynchronously(range: .global) - return FrontpageGalleriesRequest(filter: filter).effect - .map(FrontpageAction.fetchGalleriesDone) - .cancellable(id: FrontpageState.CancelID()) - - case .fetchGalleriesDone(let result): - state.loadingState = .idle - switch result { - case .success(let (pageNumber, galleries)): - guard !galleries.isEmpty else { - state.loadingState = .failed(.notFound) - guard pageNumber.hasNextPage() else { return .none } - return .init(value: .fetchMoreGalleries) - } - state.pageNumber = pageNumber - state.galleries = galleries - return environment.databaseClient.cacheGalleries(galleries).fireAndForget() - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .fetchMoreGalleries: - let pageNumber = state.pageNumber - guard pageNumber.hasNextPage(), - state.footerLoadingState != .loading, - let lastID = state.galleries.last?.id - else { return .none } - state.footerLoadingState = .loading - let filter = environment.databaseClient.fetchFilterSynchronously(range: .global) - return MoreFrontpageGalleriesRequest(filter: filter, lastID: lastID).effect - .map(FrontpageAction.fetchMoreGalleriesDone) - .cancellable(id: FrontpageState.CancelID()) - - case .fetchMoreGalleriesDone(let result): - state.footerLoadingState = .idle - switch result { - case .success(let (pageNumber, galleries)): - state.pageNumber = pageNumber - state.insertGalleries(galleries) - - var effects: [Effect] = [ - environment.databaseClient.cacheGalleries(galleries).fireAndForget() - ] - if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.init(value: .fetchMoreGalleries)) - } else if !galleries.isEmpty { - state.loadingState = .idle - } - return .merge(effects) - - case .failure(let error): - state.footerLoadingState = .failed(error) - } - return .none - - case .filters: - return .none - - case .detail: - return .none - } - } - .haptics( - unwrapping: \.route, - case: /FrontpageState.Route.filters, - hapticClient: \.hapticClient - ) - .binding(), - filtersReducer.pullback( - state: \.filtersState, - action: /FrontpageAction.filters, - environment: { - .init( - databaseClient: $0.databaseClient - ) - } - ), - detailReducer.pullback( - state: \.detailState, - action: /FrontpageAction.detail, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ) -) diff --git a/EhPanda/View/Home/DataFlow/HistoryStore.swift b/EhPanda/View/Home/DataFlow/HistoryStore.swift deleted file mode 100644 index e5bd43bf..00000000 --- a/EhPanda/View/Home/DataFlow/HistoryStore.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// HistoryStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/09. -// - -import ComposableArchitecture - -struct HistoryState: Equatable { - enum Route: Equatable { - case detail(String) - case clearHistory - } - - init() { - _detailState = .init(.init()) - } - - @BindableState var route: Route? - @BindableState var keyword = "" - @BindableState var clearDialogPresented = false - - var filteredGalleries: [Gallery] { - guard !keyword.isEmpty else { return galleries } - return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) - } - var galleries = [Gallery]() - var loadingState: LoadingState = .idle - - @Heap var detailState: DetailState! -} - -enum HistoryAction: BindableAction { - case binding(BindingAction) - case setNavigation(HistoryState.Route?) - case clearSubStates - case clearHistoryGalleries - - case fetchGalleries - case fetchGalleriesDone([Gallery]) - - case detail(DetailAction) -} - -struct HistoryEnvironment { - let urlClient: URLClient - let fileClient: FileClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient - let uiApplicationClient: UIApplicationClient -} - -let historyReducer = Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .clearSubStates: - state.detailState = .init() - return .init(value: .detail(.teardown)) - - case .clearHistoryGalleries: - return .merge( - environment.databaseClient.clearHistoryGalleries().fireAndForget(), - .init(value: .fetchGalleries) - .delay(for: .milliseconds(200), scheduler: DispatchQueue.main).eraseToEffect() - ) - - case .fetchGalleries: - guard state.loadingState != .loading else { return .none } - state.loadingState = .loading - return environment.databaseClient.fetchHistoryGalleries().map(HistoryAction.fetchGalleriesDone) - - case .fetchGalleriesDone(let galleries): - state.loadingState = .idle - if galleries.isEmpty { - state.loadingState = .failed(.notFound) - } else { - state.galleries = galleries - } - return .none - - case .detail: - return .none - } - } - .binding(), - detailReducer.pullback( - state: \.detailState, - action: /HistoryAction.detail, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ) -) diff --git a/EhPanda/View/Home/DataFlow/HomeStore.swift b/EhPanda/View/Home/DataFlow/HomeStore.swift deleted file mode 100644 index a51fc357..00000000 --- a/EhPanda/View/Home/DataFlow/HomeStore.swift +++ /dev/null @@ -1,367 +0,0 @@ -// -// HomeStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/05. -// - -import SwiftUI -import Kingfisher -import UIImageColors -import ComposableArchitecture - -struct HomeState: Equatable { - enum Route: Equatable, Hashable { - case detail(String) - case misc(HomeMiscGridType) - case section(HomeSectionType) - } - - init() { - _detailState = .init(.init()) - } - - @BindableState var route: Route? - @BindableState var cardPageIndex = 1 - @BindableState var currentCardID = "" - var allowsCardHitTesting = true - var rawCardColors = [String: [Color]]() - var cardColors: [Color] { - rawCardColors[currentCardID] ?? [.clear] - } - - var popularGalleries = [Gallery]() - var popularLoadingState: LoadingState = .idle - var frontpageGalleries = [Gallery]() - var frontpageLoadingState: LoadingState = .idle - var toplistsGalleries = [Int: [Gallery]]() - var toplistsLoadingState = [Int: LoadingState]() - - var frontpageState = FrontpageState() - var toplistsState = ToplistsState() - var popularState = PopularState() - var watchedState = WatchedState() - var historyState = HistoryState() - @Heap var detailState: DetailState! - - mutating func setPopularGalleries(_ galleries: [Gallery]) { - let sortedGalleries = galleries.sorted { lhs, rhs in - lhs.title.count > rhs.title.count - } - var trimmedGalleries = Array(sortedGalleries.prefix(min(sortedGalleries.count, 10))) - .removeDuplicates(by: \.trimmedTitle) - if trimmedGalleries.count >= 6 { - trimmedGalleries = Array(trimmedGalleries.prefix(6)) - } - trimmedGalleries.shuffle() - popularGalleries = trimmedGalleries - currentCardID = trimmedGalleries[cardPageIndex].gid - } - mutating func setFrontpageGalleries(_ galleries: [Gallery]) { - frontpageGalleries = Array(galleries.prefix(min(galleries.count, 25))) - .removeDuplicates(by: \.trimmedTitle) - } -} - -enum HomeAction: BindableAction { - case binding(BindingAction) - case setNavigation(HomeState.Route?) - case clearSubStates - case setAllowsCardHitTesting(Bool) - case analyzeImageColors(String, RetrieveImageResult) - case analyzeImageColorsDone(String, UIImageColors?) - - case fetchAllGalleries - case fetchAllToplistsGalleries - case fetchPopularGalleries - case fetchPopularGalleriesDone(Result<[Gallery], AppError>) - case fetchFrontpageGalleries - case fetchFrontpageGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - case fetchToplistsGalleries(Int, Int? = nil) - case fetchToplistsGalleriesDone(Int, Result<(PageNumber, [Gallery]), AppError>) - - case frontpage(FrontpageAction) - case toplists(ToplistsAction) - case popular(PopularAction) - case watched(WatchedAction) - case history(HistoryAction) - case detail(DetailAction) -} - -struct HomeEnvironment { - let urlClient: URLClient - let fileClient: FileClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let hapticClient: HapticClient - let libraryClient: LibraryClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient - let uiApplicationClient: UIApplicationClient -} - -let homeReducer = Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding(\.$cardPageIndex): - guard state.cardPageIndex < state.popularGalleries.count else { return .none } - state.currentCardID = state.popularGalleries[state.cardPageIndex].gid - state.allowsCardHitTesting = false - return .init(value: .setAllowsCardHitTesting(true)) - .delay(for: .milliseconds(300), scheduler: DispatchQueue.main) - .eraseToEffect() - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .clearSubStates: - state.frontpageState = .init() - state.toplistsState = .init() - state.popularState = .init() - state.watchedState = .init() - state.historyState = .init() - state.detailState = .init() - return .merge( - .init(value: .frontpage(.teardown)), - .init(value: .toplists(.teardown)), - .init(value: .popular(.teardown)), - .init(value: .watched(.teardown)), - .init(value: .detail(.teardown)) - ) - - case .setAllowsCardHitTesting(let isAllowed): - state.allowsCardHitTesting = isAllowed - return .none - - case .fetchAllGalleries: - return .merge( - .init(value: .fetchPopularGalleries), - .init(value: .fetchFrontpageGalleries), - .init(value: .fetchAllToplistsGalleries) - ) - - case .fetchAllToplistsGalleries: - return .merge( - ToplistsType.allCases.map({ HomeAction.fetchToplistsGalleries($0.categoryIndex) }) - .map(Effect.init) - ) - - case .fetchPopularGalleries: - guard state.popularLoadingState != .loading else { return .none } - state.popularLoadingState = .loading - state.rawCardColors = [String: [Color]]() - let filter = environment.databaseClient.fetchFilterSynchronously(range: .global) - return PopularGalleriesRequest(filter: filter) - .effect.map(HomeAction.fetchPopularGalleriesDone) - - case .fetchPopularGalleriesDone(let result): - state.popularLoadingState = .idle - switch result { - case .success(let galleries): - guard !galleries.isEmpty else { - state.popularLoadingState = .failed(.notFound) - return .none - } - state.setPopularGalleries(galleries) - return environment.databaseClient.cacheGalleries(galleries).fireAndForget() - case .failure(let error): - state.popularLoadingState = .failed(error) - } - return .none - - case .fetchFrontpageGalleries: - guard state.frontpageLoadingState != .loading else { return .none } - state.frontpageLoadingState = .loading - let filter = environment.databaseClient.fetchFilterSynchronously(range: .global) - return FrontpageGalleriesRequest(filter: filter) - .effect.map(HomeAction.fetchFrontpageGalleriesDone) - - case .fetchFrontpageGalleriesDone(let result): - state.frontpageLoadingState = .idle - switch result { - case .success(let (_, galleries)): - guard !galleries.isEmpty else { - state.frontpageLoadingState = .failed(.notFound) - return .none - } - state.setFrontpageGalleries(galleries) - return environment.databaseClient.cacheGalleries(galleries).fireAndForget() - case .failure(let error): - state.frontpageLoadingState = .failed(error) - } - return .none - - case .fetchToplistsGalleries(let index, let pageNum): - guard state.toplistsLoadingState[index] != .loading else { return .none } - state.toplistsLoadingState[index] = .loading - return ToplistsGalleriesRequest(catIndex: index, pageNum: pageNum) - .effect.map({ HomeAction.fetchToplistsGalleriesDone(index, $0) }) - - case .fetchToplistsGalleriesDone(let index, let result): - state.toplistsLoadingState[index] = .idle - switch result { - case .success(let (_, galleries)): - guard !galleries.isEmpty else { - state.toplistsLoadingState[index] = .failed(.notFound) - return .none - } - state.toplistsGalleries[index] = galleries - return environment.databaseClient.cacheGalleries(galleries).fireAndForget() - case .failure(let error): - state.toplistsLoadingState[index] = .failed(error) - } - return .none - - case .analyzeImageColors(let gid, let result): - guard !state.rawCardColors.keys.contains(gid) else { return .none } - return environment.libraryClient.analyzeImageColors(result.image) - .map({ HomeAction.analyzeImageColorsDone(gid, $0) }) - - case .analyzeImageColorsDone(let gid, let colors): - if let colors = colors { - state.rawCardColors[gid] = [ - colors.primary, colors.secondary, - colors.detail, colors.background - ] - .map(Color.init) - } - return .none - - case .frontpage: - return .none - - case .toplists: - return .none - - case .popular: - return .none - - case .watched: - return .none - - case .history: - return .none - - case .detail: - return .none - } - } - .binding(), - frontpageReducer.pullback( - state: \.frontpageState, - action: /HomeAction.frontpage, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ), - toplistsReducer.pullback( - state: \.toplistsState, - action: /HomeAction.toplists, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ), - popularReducer.pullback( - state: \.popularState, - action: /HomeAction.popular, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ), - watchedReducer.pullback( - state: \.watchedState, - action: /HomeAction.watched, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ), - historyReducer.pullback( - state: \.historyState, - action: /HomeAction.history, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ), - detailReducer.pullback( - state: \.detailState, - action: /HomeAction.detail, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ) -) diff --git a/EhPanda/View/Home/DataFlow/PopularStore.swift b/EhPanda/View/Home/DataFlow/PopularStore.swift deleted file mode 100644 index d770451c..00000000 --- a/EhPanda/View/Home/DataFlow/PopularStore.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// PopularStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/09. -// - -import ComposableArchitecture - -struct PopularState: Equatable { - enum Route: Equatable { - case filters - case detail(String) - } - struct CancelID: Hashable { - let id = String(describing: PopularState.self) - } - - init() { - _detailState = .init(.init()) - } - - @BindableState var route: Route? - @BindableState var keyword = "" - - var filteredGalleries: [Gallery] { - guard !keyword.isEmpty else { return galleries } - return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) - } - var galleries = [Gallery]() - var loadingState: LoadingState = .idle - - var filtersState = FiltersState() - @Heap var detailState: DetailState! -} - -enum PopularAction: BindableAction { - case binding(BindingAction) - case setNavigation(PopularState.Route?) - case clearSubStates - - case teardown - case fetchGalleries - case fetchGalleriesDone(Result<[Gallery], AppError>) - - case filters(FiltersAction) - case detail(DetailAction) -} - -struct PopularEnvironment { - let urlClient: URLClient - let fileClient: FileClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient - let uiApplicationClient: UIApplicationClient -} - -let popularReducer = Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .clearSubStates: - state.detailState = .init() - state.filtersState = .init() - return .init(value: .detail(.teardown)) - - case .teardown: - return .cancel(id: PopularState.CancelID()) - - case .fetchGalleries: - guard state.loadingState != .loading else { return .none } - state.loadingState = .loading - let filter = environment.databaseClient.fetchFilterSynchronously(range: .global) - return PopularGalleriesRequest(filter: filter) - .effect.map(PopularAction.fetchGalleriesDone).cancellable(id: PopularState.CancelID()) - - case .fetchGalleriesDone(let result): - state.loadingState = .idle - switch result { - case .success(let galleries): - guard !galleries.isEmpty else { - state.loadingState = .failed(.notFound) - return .none - } - state.galleries = galleries - return environment.databaseClient.cacheGalleries(galleries).fireAndForget() - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .filters: - return .none - - case .detail: - return .none - } - } - .haptics( - unwrapping: \.route, - case: /PopularState.Route.filters, - hapticClient: \.hapticClient - ) - .binding(), - filtersReducer.pullback( - state: \.filtersState, - action: /PopularAction.filters, - environment: { - .init( - databaseClient: $0.databaseClient - ) - } - ), - detailReducer.pullback( - state: \.detailState, - action: /PopularAction.detail, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ) -) diff --git a/EhPanda/View/Home/DataFlow/ToplistsStore.swift b/EhPanda/View/Home/DataFlow/ToplistsStore.swift deleted file mode 100644 index 281a4f39..00000000 --- a/EhPanda/View/Home/DataFlow/ToplistsStore.swift +++ /dev/null @@ -1,229 +0,0 @@ -// -// ToplistsStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/08. -// - -import ComposableArchitecture - -struct ToplistsState: Equatable { - enum Route: Equatable { - case detail(String) - } - struct CancelID: Hashable { - let id = String(describing: ToplistsState.self) - } - - init() { - _detailState = .init(.init()) - } - - @BindableState var route: Route? - @BindableState var keyword = "" - @BindableState var jumpPageIndex = "" - @BindableState var jumpPageAlertFocused = false - @BindableState var jumpPageAlertPresented = false - - var type: ToplistsType = .yesterday - - var filteredGalleries: [Gallery]? { - guard !keyword.isEmpty else { return galleries } - return galleries?.filter({ $0.title.caseInsensitiveContains(keyword) }) - } - - var rawGalleries = [ToplistsType: [Gallery]]() - var rawPageNumber = [ToplistsType: PageNumber]() - var rawLoadingState = [ToplistsType: LoadingState]() - var rawFooterLoadingState = [ToplistsType: LoadingState]() - - var galleries: [Gallery]? { - rawGalleries[type] - } - var pageNumber: PageNumber? { - rawPageNumber[type] - } - var loadingState: LoadingState? { - rawLoadingState[type] - } - var footerLoadingState: LoadingState? { - rawFooterLoadingState[type] - } - - @Heap var detailState: DetailState! - - mutating func insertGalleries(type: ToplistsType, galleries: [Gallery]) { - galleries.forEach { gallery in - if rawGalleries[type]?.contains(gallery) == false { - rawGalleries[type]?.append(gallery) - } - } - } -} - -enum ToplistsAction: BindableAction { - case binding(BindingAction) - case setNavigation(ToplistsState.Route?) - case setToplistsType(ToplistsType) - case clearSubStates - - case performJumpPage - case presentJumpPageAlert - case setJumpPageAlertFocused(Bool) - - case teardown - case fetchGalleries(Int? = nil) - case fetchGalleriesDone(ToplistsType, Result<(PageNumber, [Gallery]), AppError>) - case fetchMoreGalleries - case fetchMoreGalleriesDone(ToplistsType, Result<(PageNumber, [Gallery]), AppError>) - - case detail(DetailAction) -} - -struct ToplistsEnvironment { - let urlClient: URLClient - let fileClient: FileClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient - let uiApplicationClient: UIApplicationClient -} - -let toplistsReducer = Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding(\.$jumpPageAlertPresented): - if !state.jumpPageAlertPresented { - state.jumpPageAlertFocused = false - } - return .none - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .setToplistsType(let type): - state.type = type - guard state.galleries?.isEmpty != false else { return .none } - return .init(value: ToplistsAction.fetchGalleries()) - - case .clearSubStates: - state.detailState = .init() - return .init(value: .detail(.teardown)) - - case .performJumpPage: - guard let index = Int(state.jumpPageIndex), - let pageNumber = state.pageNumber, - index > 0, index <= pageNumber.maximum + 1 else { - return environment.hapticClient.generateNotificationFeedback(.error).fireAndForget() - } - return .init(value: .fetchGalleries(index - 1)) - - case .presentJumpPageAlert: - state.jumpPageAlertPresented = true - return environment.hapticClient.generateFeedback(.light).fireAndForget() - - case .setJumpPageAlertFocused(let isFocused): - state.jumpPageAlertFocused = isFocused - return .none - - case .teardown: - return .cancel(id: ToplistsState.CancelID()) - - case .fetchGalleries(let pageNum): - guard state.loadingState != .loading else { return .none } - state.rawLoadingState[state.type] = .loading - if state.pageNumber == nil { - state.rawPageNumber[state.type] = PageNumber() - } else { - state.rawPageNumber[state.type]?.resetPages() - } - return ToplistsGalleriesRequest(catIndex: state.type.categoryIndex, pageNum: pageNum) - .effect.map({ [type = state.type] in ToplistsAction.fetchGalleriesDone(type, $0) }) - .cancellable(id: ToplistsState.CancelID()) - - case .fetchGalleriesDone(let type, let result): - state.rawLoadingState[type] = .idle - switch result { - case .success(let (pageNumber, galleries)): - guard !galleries.isEmpty else { - state.rawLoadingState[type] = .failed(.notFound) - guard pageNumber.hasNextPage() else { return .none } - return .init(value: .fetchMoreGalleries) - } - state.rawPageNumber[type] = pageNumber - state.rawGalleries[type] = galleries - return environment.databaseClient.cacheGalleries(galleries).fireAndForget() - case .failure(let error): - state.rawLoadingState[type] = .failed(error) - } - return .none - - case .fetchMoreGalleries: - let pageNumber = state.pageNumber ?? .init() - guard pageNumber.hasNextPage(), - state.footerLoadingState != .loading, - let lastID = state.rawGalleries[state.type]?.last?.id - else { return .none } - state.rawFooterLoadingState[state.type] = .loading - let pageNum = pageNumber.current + 1 - return MoreToplistsGalleriesRequest(catIndex: state.type.categoryIndex, pageNum: pageNum) - .effect.map({ [type = state.type] in ToplistsAction.fetchMoreGalleriesDone(type, $0) }) - .cancellable(id: ToplistsState.CancelID()) - - case .fetchMoreGalleriesDone(let type, let result): - state.rawFooterLoadingState[type] = .idle - switch result { - case .success(let (pageNumber, galleries)): - state.rawPageNumber[type] = pageNumber - state.insertGalleries(type: type, galleries: galleries) - - var effects: [Effect] = [ - environment.databaseClient.cacheGalleries(galleries).fireAndForget() - ] - if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.init(value: .fetchMoreGalleries)) - } else if !galleries.isEmpty { - state.rawLoadingState[type] = .idle - } - return .merge(effects) - - case .failure(let error): - state.rawFooterLoadingState[type] = .failed(error) - } - return .none - - case .detail: - return .none - } - } - .binding(), - detailReducer.pullback( - state: \.detailState, - action: /ToplistsAction.detail, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ) -) diff --git a/EhPanda/View/Home/DataFlow/WatchedStore.swift b/EhPanda/View/Home/DataFlow/WatchedStore.swift deleted file mode 100644 index ca63cd39..00000000 --- a/EhPanda/View/Home/DataFlow/WatchedStore.swift +++ /dev/null @@ -1,222 +0,0 @@ -// -// WatchedStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/09. -// - -import ComposableArchitecture - -struct WatchedState: Equatable { - enum Route: Equatable { - case filters - case quickSearch - case detail(String) - } - struct CancelID: Hashable { - let id = String(describing: WatchedState.self) - } - - init() { - _detailState = .init(.init()) - } - - @BindableState var route: Route? - @BindableState var keyword = "" - - var galleries = [Gallery]() - var pageNumber = PageNumber() - var loadingState: LoadingState = .idle - var footerLoadingState: LoadingState = .idle - - var filtersState = FiltersState() - var quickSearchState = QuickSearchState() - @Heap var detailState: DetailState! - - mutating func insertGalleries(_ galleries: [Gallery]) { - galleries.forEach { gallery in - if !self.galleries.contains(gallery) { - self.galleries.append(gallery) - } - } - } -} - -enum WatchedAction: BindableAction { - case binding(BindingAction) - case setNavigation(WatchedState.Route?) - case clearSubStates - case onNotLoginViewButtonTapped - - case teardown - case fetchGalleries(String? = nil) - case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - case fetchMoreGalleries - case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - - case filters(FiltersAction) - case detail(DetailAction) - case quickSearch(QuickSearchAction) -} - -struct WatchedEnvironment { - let urlClient: URLClient - let fileClient: FileClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient - let uiApplicationClient: UIApplicationClient -} - -let watchedReducer = Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .clearSubStates: - state.detailState = .init() - state.filtersState = .init() - state.quickSearchState = .init() - return .merge( - .init(value: .detail(.teardown)), - .init(value: .quickSearch(.teardown)) - ) - - case .onNotLoginViewButtonTapped: - return .none - - case .teardown: - return .cancel(id: WatchedState.CancelID()) - - case .fetchGalleries(let keyword): - guard state.loadingState != .loading else { return .none } - if let keyword = keyword { - state.keyword = keyword - } - state.loadingState = .loading - state.pageNumber.resetPages() - let filter = environment.databaseClient.fetchFilterSynchronously(range: .watched) - return WatchedGalleriesRequest(filter: filter, keyword: state.keyword) - .effect.map(WatchedAction.fetchGalleriesDone).cancellable(id: WatchedState.CancelID()) - - case .fetchGalleriesDone(let result): - state.loadingState = .idle - switch result { - case .success(let (pageNumber, galleries)): - guard !galleries.isEmpty else { - state.loadingState = .failed(.notFound) - guard pageNumber.hasNextPage() else { return .none } - return .init(value: .fetchMoreGalleries) - } - state.pageNumber = pageNumber - state.galleries = galleries - return environment.databaseClient.cacheGalleries(galleries).fireAndForget() - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .fetchMoreGalleries: - let pageNumber = state.pageNumber - guard pageNumber.hasNextPage(), - state.footerLoadingState != .loading, - let lastID = state.galleries.last?.id - else { return .none } - state.footerLoadingState = .loading - let filter = environment.databaseClient.fetchFilterSynchronously(range: .watched) - return MoreWatchedGalleriesRequest(filter: filter, lastID: lastID, keyword: state.keyword).effect - .map(WatchedAction.fetchMoreGalleriesDone) - .cancellable(id: WatchedState.CancelID()) - - case .fetchMoreGalleriesDone(let result): - state.footerLoadingState = .idle - switch result { - case .success(let (pageNumber, galleries)): - state.pageNumber = pageNumber - state.insertGalleries(galleries) - - var effects: [Effect] = [ - environment.databaseClient.cacheGalleries(galleries).fireAndForget() - ] - if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.init(value: .fetchMoreGalleries)) - } else if !galleries.isEmpty { - state.loadingState = .idle - } - return .merge(effects) - - case .failure(let error): - state.footerLoadingState = .failed(error) - } - return .none - - case .quickSearch: - return .none - - case .filters: - return .none - - case .detail: - return .none - } - } - .haptics( - unwrapping: \.route, - case: /WatchedState.Route.quickSearch, - hapticClient: \.hapticClient - ) - .haptics( - unwrapping: \.route, - case: /WatchedState.Route.filters, - hapticClient: \.hapticClient - ) - .binding(), - filtersReducer.pullback( - state: \.filtersState, - action: /WatchedAction.filters, - environment: { - .init( - databaseClient: $0.databaseClient - ) - } - ), - quickSearchReducer.pullback( - state: \.quickSearchState, - action: /WatchedAction.quickSearch, - environment: { - .init( - databaseClient: $0.databaseClient - ) - } - ), - detailReducer.pullback( - state: \.detailState, - action: /WatchedAction.detail, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ) -) diff --git a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift new file mode 100644 index 00000000..b4574355 --- /dev/null +++ b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift @@ -0,0 +1,166 @@ +// +// FrontpageReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/08. +// + +import ComposableArchitecture + +struct FrontpageReducer: ReducerProtocol { + enum Route: Equatable { + case filters + case detail(String) + } + + private enum CancelID: CaseIterable { + case fetchGalleries, fetchMoreGalleries + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var keyword = "" + + var filteredGalleries: [Gallery] { + guard !keyword.isEmpty else { return galleries } + return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) + } + var galleries = [Gallery]() + var pageNumber = PageNumber() + var loadingState: LoadingState = .idle + var footerLoadingState: LoadingState = .idle + + var filtersState = FiltersReducer.State() + @Heap var detailState: DetailReducer.State! + + init() { + _detailState = .init(.init()) + } + + mutating func insertGalleries(_ galleries: [Gallery]) { + galleries.forEach { gallery in + if !self.galleries.contains(gallery) { + self.galleries.append(gallery) + } + } + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case clearSubStates + + case teardown + case fetchGalleries + case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + + case filters(FiltersReducer.Action) + case detail(DetailReducer.Action) + } + + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.hapticsClient) private var hapticsClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.detailState = .init() + state.filtersState = .init() + return .init(value: .detail(.teardown)) + + case .teardown: + return .cancel(ids: CancelID.allCases) + + case .fetchGalleries: + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + state.pageNumber.resetPages() + let filter = databaseClient.fetchFilterSynchronously(range: .global) + return FrontpageGalleriesRequest(filter: filter).effect + .map(Action.fetchGalleriesDone) + .cancellable(id: CancelID.fetchGalleries) + + case .fetchGalleriesDone(let result): + state.loadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + guard pageNumber.hasNextPage() else { return .none } + return .init(value: .fetchMoreGalleries) + } + state.pageNumber = pageNumber + state.galleries = galleries + return databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber + guard pageNumber.hasNextPage(), + state.footerLoadingState != .loading, + let lastID = state.galleries.last?.id + else { return .none } + state.footerLoadingState = .loading + let filter = databaseClient.fetchFilterSynchronously(range: .global) + return MoreFrontpageGalleriesRequest(filter: filter, lastID: lastID).effect + .map(Action.fetchMoreGalleriesDone) + .cancellable(id: CancelID.fetchMoreGalleries) + + case .fetchMoreGalleriesDone(let result): + state.footerLoadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + state.pageNumber = pageNumber + state.insertGalleries(galleries) + + var effects: [EffectTask] = [ + databaseClient.cacheGalleries(galleries).fireAndForget() + ] + if galleries.isEmpty, pageNumber.hasNextPage() { + effects.append(.init(value: .fetchMoreGalleries)) + } else if !galleries.isEmpty { + state.loadingState = .idle + } + return .merge(effects) + + case .failure(let error): + state.footerLoadingState = .failed(error) + } + return .none + + case .filters: + return .none + + case .detail: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /Route.filters, + hapticsClient: hapticsClient + ) + + Scope(state: \.filtersState, action: /Action.filters, child: FiltersReducer.init) + Scope(state: \.detailState, action: /Action.detail, child: DetailReducer.init) + } +} diff --git a/EhPanda/View/Home/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift similarity index 77% rename from EhPanda/View/Home/FrontpageView.swift rename to EhPanda/View/Home/Frontpage/FrontpageView.swift index dc166a85..b82e137b 100644 --- a/EhPanda/View/Home/FrontpageView.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageView.swift @@ -10,15 +10,15 @@ import AlertKit import ComposableArchitecture struct FrontpageView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let user: User @Binding private var setting: Setting private let blurRadius: Double private let tagTranslator: TagTranslator init( - store: Store, + store: StoreOf, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store @@ -45,20 +45,20 @@ struct FrontpageView: View { ) .sheet( unwrapping: viewStore.binding(\.$route), - case: /FrontpageState.Route.detail, + case: /FrontpageReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in NavigationView { DetailView( - store: store.scope(state: \.detailState, action: FrontpageAction.detail), + store: store.scope(state: \.detailState, action: FrontpageReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) } .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /FrontpageState.Route.filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: FrontpageAction.filters)) + .sheet(unwrapping: viewStore.binding(\.$route), case: /FrontpageReducer.Route.filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: FrontpageReducer.Action.filters)) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } .searchable(text: viewStore.binding(\.$keyword), prompt: L10n.Localizable.Searchable.Prompt.filter) @@ -76,9 +76,9 @@ struct FrontpageView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /FrontpageState.Route.detail) { route in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /FrontpageReducer.Route.detail) { route in DetailView( - store: store.scope(state: \.detailState, action: FrontpageAction.detail), + store: store.scope(state: \.detailState, action: FrontpageReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) @@ -100,19 +100,7 @@ struct FrontpageView_Previews: PreviewProvider { FrontpageView( store: .init( initialState: .init(), - reducer: frontpageReducer, - environment: FrontpageEnvironment( - urlClient: .live, - fileClient: .live, - imageClient: .live, - deviceClient: .live, - hapticClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live, - uiApplicationClient: .live - ) + reducer: FrontpageReducer() ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Home/History/HistoryReducer.swift b/EhPanda/View/Home/History/HistoryReducer.swift new file mode 100644 index 00000000..547275c7 --- /dev/null +++ b/EhPanda/View/Home/History/HistoryReducer.swift @@ -0,0 +1,98 @@ +// +// HistoryReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import Foundation +import ComposableArchitecture + +struct HistoryReducer: ReducerProtocol { + enum Route: Equatable { + case detail(String) + case clearHistory + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var keyword = "" + @BindingState var clearDialogPresented = false + + var filteredGalleries: [Gallery] { + guard !keyword.isEmpty else { return galleries } + return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) + } + var galleries = [Gallery]() + var loadingState: LoadingState = .idle + + @Heap var detailState: DetailReducer.State! + + init() { + _detailState = .init(.init()) + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case clearSubStates + case clearHistoryGalleries + + case fetchGalleries + case fetchGalleriesDone([Gallery]) + + case detail(DetailReducer.Action) + } + + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.hapticsClient) private var hapticsClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.detailState = .init() + return .init(value: .detail(.teardown)) + + case .clearHistoryGalleries: + return .merge( + databaseClient.clearHistoryGalleries().fireAndForget(), + .init(value: .fetchGalleries) + .delay(for: .milliseconds(200), scheduler: DispatchQueue.main).eraseToEffect() + ) + + case .fetchGalleries: + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + return databaseClient.fetchHistoryGalleries().map(Action.fetchGalleriesDone) + + case .fetchGalleriesDone(let galleries): + state.loadingState = .idle + if galleries.isEmpty { + state.loadingState = .failed(.notFound) + } else { + state.galleries = galleries + } + return .none + + case .detail: + return .none + } + } + + Scope(state: \.detailState, action: /Action.detail, child: DetailReducer.init) + } +} diff --git a/EhPanda/View/Home/HistoryView.swift b/EhPanda/View/Home/History/HistoryView.swift similarity index 79% rename from EhPanda/View/Home/HistoryView.swift rename to EhPanda/View/Home/History/HistoryView.swift index 58d7523a..59e409bc 100644 --- a/EhPanda/View/Home/HistoryView.swift +++ b/EhPanda/View/Home/History/HistoryView.swift @@ -9,15 +9,15 @@ import SwiftUI import ComposableArchitecture struct HistoryView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let user: User @Binding private var setting: Setting private let blurRadius: Double private let tagTranslator: TagTranslator init( - store: Store, + store: StoreOf, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store @@ -43,12 +43,12 @@ struct HistoryView: View { ) .sheet( unwrapping: viewStore.binding(\.$route), - case: /HistoryState.Route.detail, + case: /HistoryReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in NavigationView { DetailView( - store: store.scope(state: \.detailState, action: HistoryAction.detail), + store: store.scope(state: \.detailState, action: HistoryReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) @@ -70,9 +70,9 @@ struct HistoryView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HistoryState.Route.detail) { route in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HistoryReducer.Route.detail) { route in DetailView( - store: store.scope(state: \.detailState, action: HistoryAction.detail), + store: store.scope(state: \.detailState, action: HistoryReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) @@ -90,7 +90,7 @@ struct HistoryView: View { .confirmationDialog( message: L10n.Localizable.ConfirmationDialog.Title.clear, unwrapping: viewStore.binding(\.$route), - case: /HistoryState.Route.clearHistory + case: /HistoryReducer.Route.clearHistory ) { Button(L10n.Localizable.ConfirmationDialog.Button.clear, role: .destructive) { viewStore.send(.clearHistoryGalleries) @@ -106,19 +106,7 @@ struct HistoryView_Previews: PreviewProvider { HistoryView( store: .init( initialState: .init(), - reducer: historyReducer, - environment: HistoryEnvironment( - urlClient: .live, - fileClient: .live, - imageClient: .live, - deviceClient: .live, - hapticClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live, - uiApplicationClient: .live - ) + reducer: HistoryReducer() ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Home/HomeReducer.swift b/EhPanda/View/Home/HomeReducer.swift new file mode 100644 index 00000000..0089bd72 --- /dev/null +++ b/EhPanda/View/Home/HomeReducer.swift @@ -0,0 +1,259 @@ +// +// HomeReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/05. +// + +import SwiftUI +import Kingfisher +import UIImageColors +import ComposableArchitecture + +struct HomeReducer: ReducerProtocol { + enum Route: Equatable, Hashable { + case detail(String) + case misc(HomeMiscGridType) + case section(HomeSectionType) + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var cardPageIndex = 1 + @BindingState var currentCardID = "" + var allowsCardHitTesting = true + var rawCardColors = [String: [Color]]() + var cardColors: [Color] { + rawCardColors[currentCardID] ?? [.clear] + } + + var popularGalleries = [Gallery]() + var popularLoadingState: LoadingState = .idle + var frontpageGalleries = [Gallery]() + var frontpageLoadingState: LoadingState = .idle + var toplistsGalleries = [Int: [Gallery]]() + var toplistsLoadingState = [Int: LoadingState]() + + var frontpageState = FrontpageReducer.State() + var toplistsState = ToplistsReducer.State() + var popularState = PopularReducer.State() + var watchedState = WatchedReducer.State() + var historyState = HistoryReducer.State() + @Heap var detailState: DetailReducer.State! + + init() { + _detailState = .init(.init()) + } + + mutating func setPopularGalleries(_ galleries: [Gallery]) { + let sortedGalleries = galleries.sorted { lhs, rhs in + lhs.title.count > rhs.title.count + } + var trimmedGalleries = Array(sortedGalleries.prefix(min(sortedGalleries.count, 10))) + .removeDuplicates(by: \.trimmedTitle) + if trimmedGalleries.count >= 6 { + trimmedGalleries = Array(trimmedGalleries.prefix(6)) + } + trimmedGalleries.shuffle() + popularGalleries = trimmedGalleries + currentCardID = trimmedGalleries[cardPageIndex].gid + } + + mutating func setFrontpageGalleries(_ galleries: [Gallery]) { + frontpageGalleries = Array(galleries.prefix(min(galleries.count, 25))) + .removeDuplicates(by: \.trimmedTitle) + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case clearSubStates + case setAllowsCardHitTesting(Bool) + case analyzeImageColors(String, RetrieveImageResult) + case analyzeImageColorsDone(String, UIImageColors?) + + case fetchAllGalleries + case fetchAllToplistsGalleries + case fetchPopularGalleries + case fetchPopularGalleriesDone(Result<[Gallery], AppError>) + case fetchFrontpageGalleries + case fetchFrontpageGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchToplistsGalleries(Int, Int? = nil) + case fetchToplistsGalleriesDone(Int, Result<(PageNumber, [Gallery]), AppError>) + + case frontpage(FrontpageReducer.Action) + case toplists(ToplistsReducer.Action) + case popular(PopularReducer.Action) + case watched(WatchedReducer.Action) + case history(HistoryReducer.Action) + case detail(DetailReducer.Action) + } + + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.libraryClient) private var libraryClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding(\.$cardPageIndex): + guard state.cardPageIndex < state.popularGalleries.count else { return .none } + state.currentCardID = state.popularGalleries[state.cardPageIndex].gid + state.allowsCardHitTesting = false + return .init(value: .setAllowsCardHitTesting(true)) + .delay(for: .milliseconds(300), scheduler: DispatchQueue.main) + .eraseToEffect() + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.frontpageState = .init() + state.toplistsState = .init() + state.popularState = .init() + state.watchedState = .init() + state.historyState = .init() + state.detailState = .init() + return .merge( + .init(value: .frontpage(.teardown)), + .init(value: .toplists(.teardown)), + .init(value: .popular(.teardown)), + .init(value: .watched(.teardown)), + .init(value: .detail(.teardown)) + ) + + case .setAllowsCardHitTesting(let isAllowed): + state.allowsCardHitTesting = isAllowed + return .none + + case .fetchAllGalleries: + return .merge( + .init(value: .fetchPopularGalleries), + .init(value: .fetchFrontpageGalleries), + .init(value: .fetchAllToplistsGalleries) + ) + + case .fetchAllToplistsGalleries: + return .merge( + ToplistsType.allCases.map({ Action.fetchToplistsGalleries($0.categoryIndex) }) + .map(EffectTask.init) + ) + + case .fetchPopularGalleries: + guard state.popularLoadingState != .loading else { return .none } + state.popularLoadingState = .loading + state.rawCardColors = [String: [Color]]() + let filter = databaseClient.fetchFilterSynchronously(range: .global) + return PopularGalleriesRequest(filter: filter) + .effect.map(Action.fetchPopularGalleriesDone) + + case .fetchPopularGalleriesDone(let result): + state.popularLoadingState = .idle + switch result { + case .success(let galleries): + guard !galleries.isEmpty else { + state.popularLoadingState = .failed(.notFound) + return .none + } + state.setPopularGalleries(galleries) + return databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.popularLoadingState = .failed(error) + } + return .none + + case .fetchFrontpageGalleries: + guard state.frontpageLoadingState != .loading else { return .none } + state.frontpageLoadingState = .loading + let filter = databaseClient.fetchFilterSynchronously(range: .global) + return FrontpageGalleriesRequest(filter: filter) + .effect.map(Action.fetchFrontpageGalleriesDone) + + case .fetchFrontpageGalleriesDone(let result): + state.frontpageLoadingState = .idle + switch result { + case .success(let (_, galleries)): + guard !galleries.isEmpty else { + state.frontpageLoadingState = .failed(.notFound) + return .none + } + state.setFrontpageGalleries(galleries) + return databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.frontpageLoadingState = .failed(error) + } + return .none + + case .fetchToplistsGalleries(let index, let pageNum): + guard state.toplistsLoadingState[index] != .loading else { return .none } + state.toplistsLoadingState[index] = .loading + return ToplistsGalleriesRequest(catIndex: index, pageNum: pageNum) + .effect.map({ Action.fetchToplistsGalleriesDone(index, $0) }) + + case .fetchToplistsGalleriesDone(let index, let result): + state.toplistsLoadingState[index] = .idle + switch result { + case .success(let (_, galleries)): + guard !galleries.isEmpty else { + state.toplistsLoadingState[index] = .failed(.notFound) + return .none + } + state.toplistsGalleries[index] = galleries + return databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.toplistsLoadingState[index] = .failed(error) + } + return .none + + case .analyzeImageColors(let gid, let result): + guard !state.rawCardColors.keys.contains(gid) else { return .none } + return libraryClient.analyzeImageColors(result.image) + .map({ Action.analyzeImageColorsDone(gid, $0) }) + + case .analyzeImageColorsDone(let gid, let colors): + if let colors = colors { + state.rawCardColors[gid] = [ + colors.primary, colors.secondary, + colors.detail, colors.background + ] + .map(Color.init) + } + return .none + + case .frontpage: + return .none + + case .toplists: + return .none + + case .popular: + return .none + + case .watched: + return .none + + case .history: + return .none + + case .detail: + return .none + } + } + + Scope(state: \.frontpageState, action: /Action.frontpage, child: FrontpageReducer.init) + Scope(state: \.toplistsState, action: /Action.toplists, child: ToplistsReducer.init) + Scope(state: \.popularState, action: /Action.popular, child: PopularReducer.init) + Scope(state: \.watchedState, action: /Action.watched, child: WatchedReducer.init) + Scope(state: \.historyState, action: /Action.history, child: HistoryReducer.init) + Scope(state: \.detailState, action: /Action.detail, child: DetailReducer.init) + } +} diff --git a/EhPanda/View/Home/HomeView.swift b/EhPanda/View/Home/HomeView.swift index 1336e143..922ee525 100644 --- a/EhPanda/View/Home/HomeView.swift +++ b/EhPanda/View/Home/HomeView.swift @@ -12,15 +12,15 @@ import SFSafeSymbols import ComposableArchitecture struct HomeView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let user: User @Binding private var setting: Setting private let blurRadius: Double private let tagTranslator: TagTranslator init( - store: Store, + store: StoreOf, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store @@ -37,7 +37,7 @@ struct HomeView: View { ZStack { ScrollView(showsIndicators: false) { VStack { - if viewStore.popularLoadingState == .loading || !viewStore.popularGalleries.isEmpty { + if !viewStore.popularGalleries.isEmpty { CardSlideSection( galleries: viewStore.popularGalleries, pageIndex: viewStore.binding(\.$cardPageIndex), @@ -51,7 +51,7 @@ struct HomeView: View { .equatable().allowsHitTesting(viewStore.allowsCardHitTesting) } Group { - if viewStore.frontpageLoadingState == .loading || viewStore.frontpageGalleries.count > 1 { + if viewStore.frontpageGalleries.count > 1 { CoverWallSection( galleries: viewStore.frontpageGalleries, isLoading: viewStore.frontpageLoadingState == .loading, @@ -89,12 +89,12 @@ struct HomeView: View { } .sheet( unwrapping: viewStore.binding(\.$route), - case: /HomeState.Route.detail, + case: /HomeReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in NavigationView { DetailView( - store: store.scope(state: \.detailState, action: HomeAction.detail), + store: store.scope(state: \.detailState, action: HomeReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) @@ -136,46 +136,46 @@ private extension HomeView { sectionLink } var detailViewLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HomeState.Route.detail) { route in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HomeReducer.Route.detail) { route in DetailView( - store: store.scope(state: \.detailState, action: HomeAction.detail), + store: store.scope(state: \.detailState, action: HomeReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) } } var miscGridLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HomeState.Route.misc) { route in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HomeReducer.Route.misc) { route in switch route.wrappedValue { case .popular: PopularView( - store: store.scope(state: \.popularState, action: HomeAction.popular), + store: store.scope(state: \.popularState, action: HomeReducer.Action.popular), user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) case .watched: WatchedView( - store: store.scope(state: \.watchedState, action: HomeAction.watched), + store: store.scope(state: \.watchedState, action: HomeReducer.Action.watched), user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) case .history: HistoryView( - store: store.scope(state: \.historyState, action: HomeAction.history), + store: store.scope(state: \.historyState, action: HomeReducer.Action.history), user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) } } } var sectionLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HomeState.Route.section) { route in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HomeReducer.Route.section) { route in switch route.wrappedValue { case .frontpage: FrontpageView( - store: store.scope(state: \.frontpageState, action: HomeAction.frontpage), + store: store.scope(state: \.frontpageState, action: HomeReducer.Action.frontpage), user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) case .toplists: ToplistsView( - store: store.scope(state: \.toplistsState, action: HomeAction.toplists), + store: store.scope(state: \.toplistsState, action: HomeReducer.Action.toplists), user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) } @@ -521,20 +521,7 @@ struct HomeView_Previews: PreviewProvider { HomeView( store: .init( initialState: .init(), - reducer: homeReducer, - environment: HomeEnvironment( - urlClient: .live, - fileClient: .live, - imageClient: .live, - deviceClient: .live, - hapticClient: .live, - libraryClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live, - uiApplicationClient: .live - ) + reducer: HomeReducer() ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Home/Popular/PopularReducer.swift b/EhPanda/View/Home/Popular/PopularReducer.swift new file mode 100644 index 00000000..a6141703 --- /dev/null +++ b/EhPanda/View/Home/Popular/PopularReducer.swift @@ -0,0 +1,116 @@ +// +// PopularReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import ComposableArchitecture + +struct PopularReducer: ReducerProtocol { + enum Route: Equatable { + case filters + case detail(String) + } + + private enum CancelID { + case fetchGalleries + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var keyword = "" + + var filteredGalleries: [Gallery] { + guard !keyword.isEmpty else { return galleries } + return galleries.filter({ $0.title.caseInsensitiveContains(keyword) }) + } + var galleries = [Gallery]() + var loadingState: LoadingState = .idle + + var filtersState = FiltersReducer.State() + @Heap var detailState: DetailReducer.State! + + init() { + _detailState = .init(.init()) + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case clearSubStates + + case teardown + case fetchGalleries + case fetchGalleriesDone(Result<[Gallery], AppError>) + + case filters(FiltersReducer.Action) + case detail(DetailReducer.Action) + } + + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.hapticsClient) private var hapticsClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.detailState = .init() + state.filtersState = .init() + return .init(value: .detail(.teardown)) + + case .teardown: + return .cancel(id: CancelID.fetchGalleries) + + case .fetchGalleries: + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + let filter = databaseClient.fetchFilterSynchronously(range: .global) + return PopularGalleriesRequest(filter: filter) + .effect.map(Action.fetchGalleriesDone).cancellable(id: CancelID.fetchGalleries) + + case .fetchGalleriesDone(let result): + state.loadingState = .idle + switch result { + case .success(let galleries): + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + return .none + } + state.galleries = galleries + return databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .filters: + return .none + + case .detail: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /Route.filters, + hapticsClient: hapticsClient + ) + + Scope(state: \.filtersState, action: /Action.filters, child: FiltersReducer.init) + Scope(state: \.detailState, action: /Action.detail, child: DetailReducer.init) + } +} diff --git a/EhPanda/View/Home/PopularView.swift b/EhPanda/View/Home/Popular/PopularView.swift similarity index 77% rename from EhPanda/View/Home/PopularView.swift rename to EhPanda/View/Home/Popular/PopularView.swift index 566da7c3..77a85236 100644 --- a/EhPanda/View/Home/PopularView.swift +++ b/EhPanda/View/Home/Popular/PopularView.swift @@ -9,15 +9,15 @@ import SwiftUI import ComposableArchitecture struct PopularView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let user: User @Binding private var setting: Setting private let blurRadius: Double private let tagTranslator: TagTranslator init( - store: Store, + store: StoreOf, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store @@ -42,20 +42,20 @@ struct PopularView: View { ) .sheet( unwrapping: viewStore.binding(\.$route), - case: /PopularState.Route.detail, + case: /PopularReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in NavigationView { DetailView( - store: store.scope(state: \.detailState, action: PopularAction.detail), + store: store.scope(state: \.detailState, action: PopularReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) } .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /PopularState.Route.filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: PopularAction.filters)) + .sheet(unwrapping: viewStore.binding(\.$route), case: /PopularReducer.Route.filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: PopularReducer.Action.filters)) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } .searchable(text: viewStore.binding(\.$keyword), prompt: L10n.Localizable.Searchable.Prompt.filter) @@ -73,9 +73,9 @@ struct PopularView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /PopularState.Route.detail) { route in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /PopularReducer.Route.detail) { route in DetailView( - store: store.scope(state: \.detailState, action: PopularAction.detail), + store: store.scope(state: \.detailState, action: PopularReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) @@ -97,19 +97,7 @@ struct PopularView_Previews: PreviewProvider { PopularView( store: .init( initialState: .init(), - reducer: popularReducer, - environment: PopularEnvironment( - urlClient: .live, - fileClient: .live, - imageClient: .live, - deviceClient: .live, - hapticClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live, - uiApplicationClient: .live - ) + reducer: PopularReducer() ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Home/Toplists/ToplistsReducer.swift b/EhPanda/View/Home/Toplists/ToplistsReducer.swift new file mode 100644 index 00000000..6497de5b --- /dev/null +++ b/EhPanda/View/Home/Toplists/ToplistsReducer.swift @@ -0,0 +1,206 @@ +// +// ToplistsReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/08. +// + +import ComposableArchitecture + +struct ToplistsReducer: ReducerProtocol { + enum Route: Equatable { + case detail(String) + } + + private enum CancelID: CaseIterable { + case fetchGalleries, fetchMoreGalleries + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var keyword = "" + @BindingState var jumpPageIndex = "" + @BindingState var jumpPageAlertFocused = false + @BindingState var jumpPageAlertPresented = false + + var type: ToplistsType = .yesterday + + var filteredGalleries: [Gallery]? { + guard !keyword.isEmpty else { return galleries } + return galleries?.filter({ $0.title.caseInsensitiveContains(keyword) }) + } + + var rawGalleries = [ToplistsType: [Gallery]]() + var rawPageNumber = [ToplistsType: PageNumber]() + var rawLoadingState = [ToplistsType: LoadingState]() + var rawFooterLoadingState = [ToplistsType: LoadingState]() + + var galleries: [Gallery]? { + rawGalleries[type] + } + var pageNumber: PageNumber? { + rawPageNumber[type] + } + var loadingState: LoadingState? { + rawLoadingState[type] + } + var footerLoadingState: LoadingState? { + rawFooterLoadingState[type] + } + + @Heap var detailState: DetailReducer.State! + + init() { + _detailState = .init(.init()) + } + + mutating func insertGalleries(type: ToplistsType, galleries: [Gallery]) { + galleries.forEach { gallery in + if rawGalleries[type]?.contains(gallery) == false { + rawGalleries[type]?.append(gallery) + } + } + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case setToplistsType(ToplistsType) + case clearSubStates + + case performJumpPage + case presentJumpPageAlert + case setJumpPageAlertFocused(Bool) + + case teardown + case fetchGalleries(Int? = nil) + case fetchGalleriesDone(ToplistsType, Result<(PageNumber, [Gallery]), AppError>) + case fetchMoreGalleries + case fetchMoreGalleriesDone(ToplistsType, Result<(PageNumber, [Gallery]), AppError>) + + case detail(DetailReducer.Action) + } + + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.hapticsClient) private var hapticsClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding(\.$jumpPageAlertPresented): + if !state.jumpPageAlertPresented { + state.jumpPageAlertFocused = false + } + return .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .setToplistsType(let type): + state.type = type + guard state.galleries?.isEmpty != false else { return .none } + return .init(value: Action.fetchGalleries()) + + case .clearSubStates: + state.detailState = .init() + return .init(value: .detail(.teardown)) + + case .performJumpPage: + guard let index = Int(state.jumpPageIndex), + let pageNumber = state.pageNumber, + index > 0, index <= pageNumber.maximum + 1 else { + return .fireAndForget({ hapticsClient.generateNotificationFeedback(.error) }) + } + return .init(value: .fetchGalleries(index - 1)) + + case .presentJumpPageAlert: + state.jumpPageAlertPresented = true + return .fireAndForget({ hapticsClient.generateFeedback(.light) }) + + case .setJumpPageAlertFocused(let isFocused): + state.jumpPageAlertFocused = isFocused + return .none + + case .teardown: + return .cancel(ids: CancelID.allCases) + + case .fetchGalleries(let pageNum): + guard state.loadingState != .loading else { return .none } + state.rawLoadingState[state.type] = .loading + if state.pageNumber == nil { + state.rawPageNumber[state.type] = PageNumber() + } else { + state.rawPageNumber[state.type]?.resetPages() + } + return ToplistsGalleriesRequest(catIndex: state.type.categoryIndex, pageNum: pageNum) + .effect.map({ [type = state.type] in Action.fetchGalleriesDone(type, $0) }) + .cancellable(id: CancelID.fetchGalleries) + + case .fetchGalleriesDone(let type, let result): + state.rawLoadingState[type] = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + state.rawLoadingState[type] = .failed(.notFound) + guard pageNumber.hasNextPage() else { return .none } + return .init(value: .fetchMoreGalleries) + } + state.rawPageNumber[type] = pageNumber + state.rawGalleries[type] = galleries + return databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.rawLoadingState[type] = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber ?? .init() + guard pageNumber.hasNextPage(), + state.footerLoadingState != .loading + else { return .none } + state.rawFooterLoadingState[state.type] = .loading + let pageNum = pageNumber.current + 1 + return MoreToplistsGalleriesRequest(catIndex: state.type.categoryIndex, pageNum: pageNum) + .effect.map({ [type = state.type] in Action.fetchMoreGalleriesDone(type, $0) }) + .cancellable(id: CancelID.fetchMoreGalleries) + + case .fetchMoreGalleriesDone(let type, let result): + state.rawFooterLoadingState[type] = .idle + switch result { + case .success(let (pageNumber, galleries)): + state.rawPageNumber[type] = pageNumber + state.insertGalleries(type: type, galleries: galleries) + + var effects: [EffectTask] = [ + databaseClient.cacheGalleries(galleries).fireAndForget() + ] + if galleries.isEmpty, pageNumber.hasNextPage() { + effects.append(.init(value: .fetchMoreGalleries)) + } else if !galleries.isEmpty { + state.rawLoadingState[type] = .idle + } + return .merge(effects) + + case .failure(let error): + state.rawFooterLoadingState[type] = .failed(error) + } + return .none + + case .detail: + return .none + } + } + + Scope(state: \.detailState, action: /Action.detail, child: DetailReducer.init) + } +} diff --git a/EhPanda/View/Home/ToplistsView.swift b/EhPanda/View/Home/Toplists/ToplistsView.swift similarity index 85% rename from EhPanda/View/Home/ToplistsView.swift rename to EhPanda/View/Home/Toplists/ToplistsView.swift index 6800f36e..e68d917a 100644 --- a/EhPanda/View/Home/ToplistsView.swift +++ b/EhPanda/View/Home/Toplists/ToplistsView.swift @@ -9,15 +9,15 @@ import SwiftUI import ComposableArchitecture struct ToplistsView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let user: User @Binding private var setting: Setting private let blurRadius: Double private let tagTranslator: TagTranslator init( - store: Store, + store: StoreOf, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store @@ -48,12 +48,12 @@ struct ToplistsView: View { ) .sheet( unwrapping: viewStore.binding(\.$route), - case: /ToplistsState.Route.detail, + case: /ToplistsReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in NavigationView { DetailView( - store: store.scope(state: \.detailState, action: ToplistsAction.detail), + store: store.scope(state: \.detailState, action: ToplistsReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) @@ -84,9 +84,9 @@ struct ToplistsView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /ToplistsState.Route.detail) { route in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /ToplistsReducer.Route.detail) { route in DetailView( - store: store.scope(state: \.detailState, action: ToplistsAction.detail), + store: store.scope(state: \.detailState, action: ToplistsReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) @@ -155,19 +155,7 @@ struct ToplistsView_Previews: PreviewProvider { ToplistsView( store: .init( initialState: .init(), - reducer: toplistsReducer, - environment: ToplistsEnvironment( - urlClient: .live, - fileClient: .live, - imageClient: .live, - deviceClient: .live, - hapticClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live, - uiApplicationClient: .live - ) + reducer: ToplistsReducer() ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Home/Watched/WatchedReducer.swift b/EhPanda/View/Home/Watched/WatchedReducer.swift new file mode 100644 index 00000000..6f583d64 --- /dev/null +++ b/EhPanda/View/Home/Watched/WatchedReducer.swift @@ -0,0 +1,184 @@ +// +// WatchedReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import ComposableArchitecture + +struct WatchedReducer: ReducerProtocol { + enum Route: Equatable { + case filters + case quickSearch + case detail(String) + } + + private enum CancelID: CaseIterable { + case fetchGalleries, fetchMoreGalleries + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var keyword = "" + + var galleries = [Gallery]() + var pageNumber = PageNumber() + var loadingState: LoadingState = .idle + var footerLoadingState: LoadingState = .idle + + var filtersState = FiltersReducer.State() + var quickSearchState = QuickSearchReducer.State() + @Heap var detailState: DetailReducer.State! + + init() { + _detailState = .init(.init()) + } + + mutating func insertGalleries(_ galleries: [Gallery]) { + galleries.forEach { gallery in + if !self.galleries.contains(gallery) { + self.galleries.append(gallery) + } + } + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case clearSubStates + case onNotLoginViewButtonTapped + + case teardown + case fetchGalleries(String? = nil) + case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + + case filters(FiltersReducer.Action) + case detail(DetailReducer.Action) + case quickSearch(QuickSearchReducer.Action) + } + + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.hapticsClient) private var hapticsClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.detailState = .init() + state.filtersState = .init() + state.quickSearchState = .init() + return .merge( + .init(value: .detail(.teardown)), + .init(value: .quickSearch(.teardown)) + ) + + case .onNotLoginViewButtonTapped: + return .none + + case .teardown: + return .cancel(ids: CancelID.allCases) + + case .fetchGalleries(let keyword): + guard state.loadingState != .loading else { return .none } + if let keyword = keyword { + state.keyword = keyword + } + state.loadingState = .loading + state.pageNumber.resetPages() + let filter = databaseClient.fetchFilterSynchronously(range: .watched) + return WatchedGalleriesRequest(filter: filter, keyword: state.keyword) + .effect.map(Action.fetchGalleriesDone).cancellable(id: CancelID.fetchGalleries) + + case .fetchGalleriesDone(let result): + state.loadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + guard pageNumber.hasNextPage() else { return .none } + return .init(value: .fetchMoreGalleries) + } + state.pageNumber = pageNumber + state.galleries = galleries + return databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber + guard pageNumber.hasNextPage(), + state.footerLoadingState != .loading, + let lastID = state.galleries.last?.id + else { return .none } + state.footerLoadingState = .loading + let filter = databaseClient.fetchFilterSynchronously(range: .watched) + return MoreWatchedGalleriesRequest(filter: filter, lastID: lastID, keyword: state.keyword).effect + .map(Action.fetchMoreGalleriesDone) + .cancellable(id: CancelID.fetchMoreGalleries) + + case .fetchMoreGalleriesDone(let result): + state.footerLoadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + state.pageNumber = pageNumber + state.insertGalleries(galleries) + + var effects: [EffectTask] = [ + databaseClient.cacheGalleries(galleries).fireAndForget() + ] + if galleries.isEmpty, pageNumber.hasNextPage() { + effects.append(.init(value: .fetchMoreGalleries)) + } else if !galleries.isEmpty { + state.loadingState = .idle + } + return .merge(effects) + + case .failure(let error): + state.footerLoadingState = .failed(error) + } + return .none + + case .quickSearch: + return .none + + case .filters: + return .none + + case .detail: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /Route.quickSearch, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.route, + case: /Route.filters, + hapticsClient: hapticsClient + ) + + Scope(state: \.filtersState, action: /Action.filters, child: FiltersReducer.init) + Scope(state: \.quickSearchState, action: /Action.quickSearch, child: QuickSearchReducer.init) + Scope(state: \.detailState, action: /Action.detail, child: DetailReducer.init) + } +} diff --git a/EhPanda/View/Home/WatchedView.swift b/EhPanda/View/Home/Watched/WatchedView.swift similarity index 78% rename from EhPanda/View/Home/WatchedView.swift rename to EhPanda/View/Home/Watched/WatchedView.swift index d486de2e..1612608b 100644 --- a/EhPanda/View/Home/WatchedView.swift +++ b/EhPanda/View/Home/Watched/WatchedView.swift @@ -9,15 +9,15 @@ import SwiftUI import ComposableArchitecture struct WatchedView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let user: User @Binding private var setting: Setting private let blurRadius: Double private let tagTranslator: TagTranslator init( - store: Store, + store: StoreOf, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store @@ -30,7 +30,7 @@ struct WatchedView: View { var body: some View { ZStack { - if CookiesUtil.didLogin { + if CookieUtil.didLogin { GenericList( galleries: viewStore.galleries, setting: setting, @@ -50,21 +50,21 @@ struct WatchedView: View { } .sheet( unwrapping: viewStore.binding(\.$route), - case: /WatchedState.Route.detail, + case: /WatchedReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in NavigationView { DetailView( - store: store.scope(state: \.detailState, action: WatchedAction.detail), + store: store.scope(state: \.detailState, action: WatchedReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) } .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /WatchedState.Route.quickSearch) { _ in + .sheet(unwrapping: viewStore.binding(\.$route), case: /WatchedReducer.Route.quickSearch) { _ in QuickSearchView( - store: store.scope(state: \.quickSearchState, action: WatchedAction.quickSearch) + store: store.scope(state: \.quickSearchState, action: WatchedReducer.Action.quickSearch) ) { keyword in viewStore.send(.setNavigation(nil)) viewStore.send(.fetchGalleries(keyword)) @@ -72,11 +72,12 @@ struct WatchedView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /WatchedState.Route.filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: WatchedAction.filters)) + .sheet(unwrapping: viewStore.binding(\.$route), case: /WatchedReducer.Route.filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: WatchedReducer.Action.filters)) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - .searchable(text: viewStore.binding(\.$keyword)) { + .searchable(text: viewStore.binding(\.$keyword)) + .searchSuggestions { TagSuggestionView( keyword: viewStore.binding(\.$keyword), translations: tagTranslator.translations, showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion @@ -86,7 +87,7 @@ struct WatchedView: View { viewStore.send(.fetchGalleries()) } .onAppear { - if viewStore.galleries.isEmpty && CookiesUtil.didLogin { + if viewStore.galleries.isEmpty && CookieUtil.didLogin { DispatchQueue.main.async { viewStore.send(.fetchGalleries()) } @@ -99,9 +100,9 @@ struct WatchedView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /WatchedState.Route.detail) { route in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /WatchedReducer.Route.detail) { route in DetailView( - store: store.scope(state: \.detailState, action: WatchedAction.detail), + store: store.scope(state: \.detailState, action: WatchedReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) @@ -128,19 +129,7 @@ struct WatchedView_Previews: PreviewProvider { WatchedView( store: .init( initialState: .init(), - reducer: watchedReducer, - environment: WatchedEnvironment( - urlClient: .live, - fileClient: .live, - imageClient: .live, - deviceClient: .live, - hapticClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live, - uiApplicationClient: .live - ) + reducer: WatchedReducer() ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Migration/MigrationReducer.swift b/EhPanda/View/Migration/MigrationReducer.swift new file mode 100644 index 00000000..3488ed04 --- /dev/null +++ b/EhPanda/View/Migration/MigrationReducer.swift @@ -0,0 +1,78 @@ +// +// MigrationReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/02/03. +// + +import Foundation +import ComposableArchitecture + +struct MigrationReducer: ReducerProtocol { + enum Route: Equatable { + case dropDialog + } + + struct State: Equatable { + @BindingState var route: Route? + var databaseState: LoadingState = .loading + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case setNavigation(Route?) + case onDatabasePreparationSuccess + + case prepareDatabase + case prepareDatabaseDone(AppError?) + case dropDatabase + case dropDatabaseDone(AppError?) + } + + @Dependency(\.databaseClient) private var databaseClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .onDatabasePreparationSuccess: + return .none + + case .prepareDatabase: + return databaseClient.prepareDatabase().map(Action.prepareDatabaseDone) + + case .prepareDatabaseDone(let appError): + if let appError { + state.databaseState = .failed(appError) + return .none + } else { + state.databaseState = .idle + return .init(value: .onDatabasePreparationSuccess) + } + + case .dropDatabase: + state.databaseState = .loading + return databaseClient.dropDatabase() + .delay(for: .milliseconds(500), scheduler: DispatchQueue.main) + .eraseToEffect().map(Action.dropDatabaseDone) + + case .dropDatabaseDone(let appError): + if let appError { + state.databaseState = .failed(appError) + return .none + } else { + state.databaseState = .idle + return .init(value: .onDatabasePreparationSuccess) + } + } + } + } +} diff --git a/EhPanda/View/Migration/MigrationStore.swift b/EhPanda/View/Migration/MigrationStore.swift deleted file mode 100644 index b3a1f944..00000000 --- a/EhPanda/View/Migration/MigrationStore.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// MigrationStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/02/03. -// - -import ComposableArchitecture - -struct MigrationState: Equatable { - enum Route: Equatable { - case dropDialog - } - - @BindableState var route: Route? - var databaseState: LoadingState = .loading -} - -enum MigrationAction: BindableAction { - case binding(BindingAction) - case setNavigation(MigrationState.Route?) - case onDatabasePreparationSuccess - - case prepareDatabase - case prepareDatabaseDone(Result) - case dropDatabase - case dropDatabaseDone(Result) -} - -struct MigrationEnvironment { - let databaseClient: DatabaseClient -} - -let migrationReducer = Reducer { state, action, environment in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return .none - - case .onDatabasePreparationSuccess: - return .none - - case .prepareDatabase: - return environment.databaseClient.prepareDatabase().map(MigrationAction.prepareDatabaseDone) - - case .prepareDatabaseDone(let result): - switch result { - case .success: - state.databaseState = .idle - return .init(value: .onDatabasePreparationSuccess) - case .failure(let error): - state.databaseState = .failed(error) - return .none - } - - case .dropDatabase: - state.databaseState = .loading - return environment.databaseClient.dropDatabase() - .delay(for: .milliseconds(500), scheduler: DispatchQueue.main) - .eraseToEffect().map(MigrationAction.dropDatabaseDone) - - case .dropDatabaseDone(let result): - switch result { - case .success: - state.databaseState = .idle - return .init(value: .onDatabasePreparationSuccess) - case .failure(let error): - state.databaseState = .failed(error) - return .none - } - } -} -.binding() diff --git a/EhPanda/View/Migration/MigrationView.swift b/EhPanda/View/Migration/MigrationView.swift index 5c45b703..5a2984f1 100644 --- a/EhPanda/View/Migration/MigrationView.swift +++ b/EhPanda/View/Migration/MigrationView.swift @@ -10,14 +10,14 @@ import ComposableArchitecture struct MigrationView: View { @Environment(\.colorScheme) private var colorScheme - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private var reversedPrimary: Color { colorScheme == .light ? .white : .black } - init(store: Store) { + init(store: StoreOf) { self.store = store viewStore = ViewStore(store) } @@ -37,7 +37,7 @@ struct MigrationView: View { .confirmationDialog( message: L10n.Localizable.ConfirmationDialog.Title.dropDatabase, unwrapping: viewStore.binding(\.$route), - case: /MigrationState.Route.dropDialog + case: /MigrationReducer.Route.dropDialog ) { Button(L10n.Localizable.ConfirmationDialog.Button.dropDatabase, role: .destructive) { viewStore.send(.dropDatabase) @@ -56,10 +56,7 @@ struct MigrationView_Previews: PreviewProvider { MigrationView( store: .init( initialState: .init(), - reducer: migrationReducer, - environment: MigrationEnvironment( - databaseClient: .live - ) + reducer: MigrationReducer() ) ) } diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift new file mode 100644 index 00000000..15bf9682 --- /dev/null +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -0,0 +1,603 @@ +// +// ReadingReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/22. +// + +import SwiftUI +import TTProgressHUD +import ComposableArchitecture + +struct ReadingReducer: ReducerProtocol { + enum Route: Equatable { + case hud + case share(ShareItem) + case readingSetting + } + + enum ShareItem: Equatable { + var associatedValue: Any { + switch self { + case .data(let data): + return data + case .image(let image): + return image + } + } + case data(Data) + case image(UIImage) + } + + enum ImageAction { + case copy(Bool) + case save(Bool) + case share(Bool) + } + + private enum CancelID: CaseIterable { + case fetchImage + case fetchDatabaseInfos + case fetchPreviewURLs + case fetchThumbnailURLs + case fetchNormalImageURLs + case refetchNormalImageURLs + case fetchMPVKeys + case fetchMPVImageURL + } + + struct State: Equatable { + @BindingState var route: Route? + var gallery: Gallery = .empty + var galleryDetail: GalleryDetail? + + var readingProgress: Int = .zero + var forceRefreshID: UUID = .init() + var hudConfig: TTProgressHUDConfig = .loading + + var webImageLoadSuccessIndices = Set() + var imageURLLoadingStates = [Int: LoadingState]() + var previewLoadingStates = [Int: LoadingState]() + var databaseLoadingState: LoadingState = .loading + var previewConfig: PreviewConfig = .normal(rows: 4) + + var previewURLs = [Int: URL]() + + var thumbnailURLs = [Int: URL]() + var imageURLs = [Int: URL]() + var originalImageURLs = [Int: URL]() + + var mpvKey: String? + var mpvImageKeys = [Int: String]() + var mpvSkipServerIdentifiers = [Int: Int]() + + @BindingState var showsPanel = false + @BindingState var showsSliderPreview = false + + // Update + func update(stored: inout [Int: T], new: [Int: T], replaceExisting: Bool = true) { + guard !new.isEmpty else { return } + stored = stored.merging(new, uniquingKeysWith: { stored, new in replaceExisting ? new : stored }) + } + mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { + update(stored: &self.previewURLs, new: previewURLs) + } + mutating func updateThumbnailURLs(_ thumbnailURLs: [Int: URL]) { + update(stored: &self.thumbnailURLs, new: thumbnailURLs) + } + mutating func updateImageURLs(_ imageURLs: [Int: URL], _ originalImageURLs: [Int: URL]) { + update(stored: &self.imageURLs, new: imageURLs) + update(stored: &self.originalImageURLs, new: originalImageURLs) + } + + // Image + func containerDataSource(setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> [Int] { + let defaultData = Array(1...gallery.pageCount) + guard isLandscape && setting.enablesDualPageMode + && setting.readingDirection != .vertical + else { return defaultData } + + let data = setting.exceptCover + ? [1] + Array(stride(from: 2, through: gallery.pageCount, by: 2)) + : Array(stride(from: 1, through: gallery.pageCount, by: 2)) + + return data + } + func imageContainerConfigs( + index: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape + ) -> ImageStackConfig { + let direction = setting.readingDirection + let isReversed = direction == .rightToLeft + let isFirstSingle = setting.exceptCover + let isFirstPageAndSingle = index == 1 && isFirstSingle + let isDualPage = isLandscape && setting.enablesDualPageMode && direction != .vertical + let firstIndex = isDualPage && isReversed && !isFirstPageAndSingle ? index + 1 : index + let secondIndex = firstIndex + (isReversed ? -1 : 1) + let isValidFirstRange = firstIndex >= 1 && firstIndex <= gallery.pageCount + let isValidSecondRange = isFirstSingle + ? secondIndex >= 2 && secondIndex <= gallery.pageCount + : secondIndex >= 1 && secondIndex <= gallery.pageCount + return .init( + firstIndex: firstIndex, secondIndex: secondIndex, isFirstAvailable: isValidFirstRange, + isSecondAvailable: !isFirstPageAndSingle && isValidSecondRange && isDualPage + ) + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + + case toggleShowsPanel + case setOrientationPortrait(Bool) + case onPerformDismiss + case onAppear(String, Bool) + + case onWebImageRetry(Int) + case onWebImageSucceeded(Int) + case onWebImageFailed(Int) + case reloadAllWebImages + case retryAllFailedWebImages + + case copyImage(URL) + case saveImage(URL) + case saveImageDone(Bool) + case shareImage(URL) + case fetchImage(ImageAction, URL) + case fetchImageDone(ImageAction, Result) + + case syncReadingProgress(Int) + case syncPreviewURLs([Int: URL]) + case syncThumbnailURLs([Int: URL]) + case syncImageURLs([Int: URL], [Int: URL]) + + case teardown + case fetchDatabaseInfos(String) + case fetchDatabaseInfosDone(GalleryState) + + case fetchPreviewURLs(Int) + case fetchPreviewURLsDone(Int, Result<[Int: URL], AppError>) + + case fetchImageURLs(Int) + case refetchImageURLs(Int) + case prefetchImages(Int, Int) + + case fetchThumbnailURLs(Int) + case fetchThumbnailURLsDone(Int, Result<[Int: URL], AppError>) + case fetchNormalImageURLs(Int, [Int: URL]) + case fetchNormalImageURLsDone(Int, Result<([Int: URL], [Int: URL]), AppError>) + case refetchNormalImageURLs(Int) + case refetchNormalImageURLsDone(Int, Result<([Int: URL], HTTPURLResponse?), AppError>) + + case fetchMPVKeys(Int, URL) + case fetchMPVKeysDone(Int, Result<(String, [Int: String]), AppError>) + case fetchMPVImageURL(Int, Bool) + case fetchMPVImageURLDone(Int, Result<(URL, URL?, Int), AppError>) + } + + @Dependency(\.appDelegateClient) private var appDelegateClient + @Dependency(\.clipboardClient) private var clipboardClient + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.cookieClient) private var cookieClient + @Dependency(\.deviceClient) private var deviceClient + @Dependency(\.imageClient) private var imageClient + @Dependency(\.urlClient) private var urlClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$showsSliderPreview): + return .fireAndForget({ hapticsClient.generateFeedback(.soft) }) + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .toggleShowsPanel: + state.showsPanel.toggle() + return .none + + case .setOrientationPortrait(let isPortrait): + var effects = [EffectTask]() + if isPortrait { + effects.append(appDelegateClient.setPortraitOrientationMask().fireAndForget()) + effects.append(appDelegateClient.setPortraitOrientation().fireAndForget()) + } else { + effects.append(appDelegateClient.setAllOrientationMask().fireAndForget()) + } + return .merge(effects) + + case .onPerformDismiss: + return .fireAndForget({ hapticsClient.generateFeedback(.light) }) + + case .onAppear(let gid, let enablesLandscape): + var effects: [EffectTask] = [ + .init(value: .fetchDatabaseInfos(gid)) + ] + if enablesLandscape { + effects.append(.init(value: .setOrientationPortrait(false))) + } + return .merge(effects) + + case .onWebImageRetry(let index): + state.imageURLLoadingStates[index] = .idle + return .none + + case .onWebImageSucceeded(let index): + state.imageURLLoadingStates[index] = .idle + state.webImageLoadSuccessIndices.insert(index) + return .none + + case .onWebImageFailed(let index): + state.imageURLLoadingStates[index] = .failed(.webImageFailed) + return .none + + case .reloadAllWebImages: + state.previewURLs = .init() + state.thumbnailURLs = .init() + state.imageURLs = .init() + state.originalImageURLs = .init() + state.mpvKey = nil + state.mpvImageKeys = .init() + state.mpvSkipServerIdentifiers = .init() + state.forceRefreshID = .init() + return databaseClient.removeImageURLs(gid: state.gallery.id).fireAndForget() + + case .retryAllFailedWebImages: + state.imageURLLoadingStates.forEach { (index, loadingState) in + if case .failed = loadingState { + state.imageURLLoadingStates[index] = .idle + } + } + state.previewLoadingStates.forEach { (index, loadingState) in + if case .failed = loadingState { + state.previewLoadingStates[index] = .idle + } + } + return .none + + case .copyImage(let imageURL): + return .init(value: .fetchImage(.copy(imageURL.isGIF), imageURL)) + + case .saveImage(let imageURL): + return .init(value: .fetchImage(.save(imageURL.isGIF), imageURL)) + + case .saveImageDone(let isSucceeded): + state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error + return .init(value: .setNavigation(.hud)) + + case .shareImage(let imageURL): + return .init(value: .fetchImage(.share(imageURL.isGIF), imageURL)) + + case .fetchImage(let action, let imageURL): + return imageClient.fetchImage(url: imageURL) + .map({ Action.fetchImageDone(action, $0) }) + .cancellable(id: CancelID.fetchImage) + + case .fetchImageDone(let action, let result): + if case .success(let image) = result { + switch action { + case .copy(let isAnimated): + state.hudConfig = .copiedToClipboardSucceeded + return .merge( + .init(value: .setNavigation(.hud)), + clipboardClient.saveImage(image, isAnimated).fireAndForget() + ) + case .save(let isAnimated): + return imageClient + .saveImageToPhotoLibrary(image, isAnimated).map(Action.saveImageDone) + case .share(let isAnimated): + if isAnimated, let data = image.kf.data(format: .GIF) { + return .init(value: .setNavigation(.share(.data(data)))) + } else { + return .init(value: .setNavigation(.share(.image(image)))) + } + } + } else { + state.hudConfig = .error + return .init(value: .setNavigation(.hud)) + } + + case .syncReadingProgress(let progress): + return databaseClient + .updateReadingProgress(gid: state.gallery.id, progress: progress).fireAndForget() + + case .syncPreviewURLs(let previewURLs): + return databaseClient + .updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs).fireAndForget() + + case .syncThumbnailURLs(let thumbnailURLs): + return databaseClient + .updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs).fireAndForget() + + case .syncImageURLs(let imageURLs, let originalImageURLs): + return databaseClient + .updateImageURLs(gid: state.gallery.id, imageURLs: imageURLs, originalImageURLs: originalImageURLs) + .fireAndForget() + + case .teardown: + var effects: [EffectTask] = [ + .cancel(ids: CancelID.allCases) + ] + if !deviceClient.isPad() { + effects.append(.init(value: .setOrientationPortrait(true))) + } + return .merge(effects) + + case .fetchDatabaseInfos(let gid): + guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } + state.gallery = gallery + state.galleryDetail = databaseClient.fetchGalleryDetail(gid: state.gallery.id) + return databaseClient.fetchGalleryState(gid: state.gallery.id) + .map(Action.fetchDatabaseInfosDone).cancellable(id: CancelID.fetchDatabaseInfos) + + case .fetchDatabaseInfosDone(let galleryState): + if let previewConfig = galleryState.previewConfig { + state.previewConfig = previewConfig + } + state.previewURLs = galleryState.previewURLs + state.imageURLs = galleryState.imageURLs + state.thumbnailURLs = galleryState.thumbnailURLs + state.originalImageURLs = galleryState.originalImageURLs + state.readingProgress = galleryState.readingProgress + state.databaseLoadingState = .idle + return .none + + case .fetchPreviewURLs(let index): + guard state.previewLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + state.previewLoadingStates[index] = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + return GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum) + .effect.map({ Action.fetchPreviewURLsDone(index, $0) }).cancellable(id: CancelID.fetchPreviewURLs) + + case .fetchPreviewURLsDone(let index, let result): + switch result { + case .success(let previewURLs): + guard !previewURLs.isEmpty else { + state.previewLoadingStates[index] = .failed(.notFound) + return .none + } + state.previewLoadingStates[index] = .idle + state.updatePreviewURLs(previewURLs) + return .init(value: .syncPreviewURLs(previewURLs)) + case .failure(let error): + state.previewLoadingStates[index] = .failed(error) + } + return .none + + case .fetchImageURLs(let index): + if state.mpvKey != nil { + return .init(value: .fetchMPVImageURL(index, false)) + } else { + return .init(value: .fetchThumbnailURLs(index)) + } + + case .refetchImageURLs(let index): + if state.mpvKey != nil { + return .init(value: .fetchMPVImageURL(index, true)) + } else { + return .init(value: .refetchNormalImageURLs(index)) + } + + case .prefetchImages(let index, let prefetchLimit): + func getPrefetchImageURLs(range: ClosedRange) -> [URL] { + (range.lowerBound...range.upperBound).compactMap { index in + if let url = state.imageURLs[index] { + return url + } + return nil + } + } + func getFetchImageURLIndices(range: ClosedRange) -> [Int] { + (range.lowerBound...range.upperBound).compactMap { index in + if state.imageURLs[index] == nil, state.imageURLLoadingStates[index] != .loading { + return index + } + return nil + } + } + var prefetchImageURLs = [URL]() + var fetchImageURLIndices = [Int]() + var effects = [EffectTask]() + let previousUpperBound = max(index - 2, 1) + let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) + if previousUpperBound - previousLowerBound > 0 { + prefetchImageURLs += getPrefetchImageURLs(range: previousLowerBound...previousUpperBound) + fetchImageURLIndices += getFetchImageURLIndices(range: previousLowerBound...previousUpperBound) + } + let nextLowerBound = min(index + 2, state.gallery.pageCount) + let nextUpperBound = min(nextLowerBound + prefetchLimit / 2, state.gallery.pageCount) + if nextUpperBound - nextLowerBound > 0 { + prefetchImageURLs += getPrefetchImageURLs(range: nextLowerBound...nextUpperBound) + fetchImageURLIndices += getFetchImageURLIndices(range: nextLowerBound...nextUpperBound) + } + fetchImageURLIndices.forEach { + effects.append(.init(value: .fetchImageURLs($0))) + } + effects.append(imageClient.prefetchImages(prefetchImageURLs).fireAndForget()) + return .merge(effects) + + case .fetchThumbnailURLs(let index): + guard state.imageURLLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL + else { return .none } + state.previewConfig.batchRange(index: index).forEach { + state.imageURLLoadingStates[$0] = .loading + } + let pageNum = state.previewConfig.pageNumber(index: index) + return ThumbnailURLsRequest(galleryURL: galleryURL, pageNum: pageNum) + .effect.map({ Action.fetchThumbnailURLsDone(index, $0) }) + .cancellable(id: CancelID.fetchThumbnailURLs) + + case .fetchThumbnailURLsDone(let index, let result): + let batchRange = state.previewConfig.batchRange(index: index) + switch result { + case .success(let thumbnailURLs): + guard !thumbnailURLs.isEmpty else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) + } + return .none + } + if let url = thumbnailURLs[index], urlClient.checkIfMPVURL(url) { + return .init(value: .fetchMPVKeys(index, url)) + } else { + state.updateThumbnailURLs(thumbnailURLs) + return .merge( + .init(value: .syncThumbnailURLs(thumbnailURLs)), + .init(value: .fetchNormalImageURLs(index, thumbnailURLs)) + ) + } + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + } + return .none + + case .fetchNormalImageURLs(let index, let thumbnailURLs): + return GalleryNormalImageURLsRequest(thumbnailURLs: thumbnailURLs) + .effect.map({ Action.fetchNormalImageURLsDone(index, $0) }) + .cancellable(id: CancelID.fetchNormalImageURLs) + + case .fetchNormalImageURLsDone(let index, let result): + let batchRange = state.previewConfig.batchRange(index: index) + switch result { + case .success(let (imageURLs, originalImageURLs)): + guard !imageURLs.isEmpty else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) + } + return .none + } + batchRange.forEach { + state.imageURLLoadingStates[$0] = .idle + } + state.updateImageURLs(imageURLs, originalImageURLs) + return .init(value: .syncImageURLs(imageURLs, originalImageURLs)) + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + } + return .none + + case .refetchNormalImageURLs(let index): + guard state.imageURLLoadingStates[index] != .loading, + let galleryURL = state.gallery.galleryURL, + let imageURL = state.imageURLs[index] + else { return .none } + state.imageURLLoadingStates[index] = .loading + let pageNum = state.previewConfig.pageNumber(index: index) + return GalleryNormalImageURLRefetchRequest( + index: index, pageNum: pageNum, + galleryURL: galleryURL, + thumbnailURL: state.thumbnailURLs[index], + storedImageURL: imageURL + ) + .effect.map({ Action.refetchNormalImageURLsDone(index, $0) }) + .cancellable(id: CancelID.refetchNormalImageURLs) + + case .refetchNormalImageURLsDone(let index, let result): + switch result { + case .success(let (imageURLs, response)): + var effects = [EffectTask]() + if let response = response { + effects.append(cookieClient.setSkipServer(response: response).fireAndForget()) + } + guard !imageURLs.isEmpty else { + state.imageURLLoadingStates[index] = .failed(.notFound) + return effects.isEmpty ? .none : .merge(effects) + } + state.imageURLLoadingStates[index] = .idle + state.updateImageURLs(imageURLs, [:]) + effects.append(.init(value: .syncImageURLs(imageURLs, [:]))) + return .merge(effects) + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) + } + return .none + + case .fetchMPVKeys(let index, let mpvURL): + return MPVKeysRequest(mpvURL: mpvURL) + .effect.map({ Action.fetchMPVKeysDone(index, $0) }).cancellable(id: CancelID.fetchMPVKeys) + + case .fetchMPVKeysDone(let index, let result): + let batchRange = state.previewConfig.batchRange(index: index) + switch result { + case .success(let (mpvKey, mpvImageKeys)): + let pageCount = state.gallery.pageCount + guard mpvImageKeys.count == pageCount else { + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(.notFound) + } + return .none + } + batchRange.forEach { + state.imageURLLoadingStates[$0] = .idle + } + state.mpvKey = mpvKey + state.mpvImageKeys = mpvImageKeys + return .merge( + Array(1...min(3, max(1, pageCount))).map { + .init(value: .fetchMPVImageURL($0, false)) + } + ) + case .failure(let error): + batchRange.forEach { + state.imageURLLoadingStates[$0] = .failed(error) + } + } + return .none + + case .fetchMPVImageURL(let index, let isRefresh): + guard let gidInteger = Int(state.gallery.id), let mpvKey = state.mpvKey, + let mpvImageKey = state.mpvImageKeys[index], + state.imageURLLoadingStates[index] != .loading + else { return .none } + state.imageURLLoadingStates[index] = .loading + let skipServerIdentifier = isRefresh ? state.mpvSkipServerIdentifiers[index] : nil + return GalleryMPVImageURLRequest( + gid: gidInteger, index: index, mpvKey: mpvKey, + mpvImageKey: mpvImageKey, skipServerIdentifier: skipServerIdentifier + ) + .effect.map({ Action.fetchMPVImageURLDone(index, $0) }).cancellable(id: CancelID.fetchMPVImageURL) + + case .fetchMPVImageURLDone(let index, let result): + switch result { + case .success(let (imageURL, originalImageURL, skipServerIdentifier)): + let imageURLs: [Int: URL] = [index: imageURL] + var originalImageURLs = [Int: URL]() + if let originalImageURL = originalImageURL { + originalImageURLs[index] = originalImageURL + } + state.imageURLLoadingStates[index] = .idle + state.mpvSkipServerIdentifiers[index] = skipServerIdentifier + state.updateImageURLs(imageURLs, originalImageURLs) + return .init(value: .syncImageURLs(imageURLs, originalImageURLs)) + case .failure(let error): + state.imageURLLoadingStates[index] = .failed(error) + } + return .none + } + } + .haptics( + unwrapping: \.route, + case: /Route.readingSetting, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.route, + case: /Route.share, + hapticsClient: hapticsClient + ) + } +} diff --git a/EhPanda/View/Reading/ReadingStore.swift b/EhPanda/View/Reading/ReadingStore.swift deleted file mode 100644 index e84a1c4e..00000000 --- a/EhPanda/View/Reading/ReadingStore.swift +++ /dev/null @@ -1,587 +0,0 @@ -// -// ReadingStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/22. -// - -import SwiftUI -import TTProgressHUD -import ComposableArchitecture - -struct ReadingState: Equatable { - enum Route: Equatable { - case hud - case share(ShareItem) - case readingSetting - } - enum ShareItem: Equatable { - var associatedValue: Any { - switch self { - case .data(let data): - return data - case .image(let image): - return image - } - } - case data(Data) - case image(UIImage) - } - enum ImageAction { - case copy(Bool) - case save(Bool) - case share(Bool) - } - struct CancelID: Hashable { - let id = String(describing: ReadingState.CancelID.self) - } - - @BindableState var route: Route? - var gallery: Gallery = .empty - var galleryDetail: GalleryDetail? - - var readingProgress: Int = .zero - var forceRefreshID: UUID = .init() - var hudConfig: TTProgressHUDConfig = .loading - - var webImageLoadSuccessIndices = Set() - var imageURLLoadingStates = [Int: LoadingState]() - var previewLoadingStates = [Int: LoadingState]() - var databaseLoadingState: LoadingState = .loading - var previewConfig: PreviewConfig = .normal(rows: 4) - - var previewURLs = [Int: URL]() - - var thumbnailURLs = [Int: URL]() - var imageURLs = [Int: URL]() - var originalImageURLs = [Int: URL]() - - var mpvKey: String? - var mpvImageKeys = [Int: String]() - var mpvSkipServerIdentifiers = [Int: String]() - - @BindableState var showsPanel = false - @BindableState var showsSliderPreview = false - - // Update - func update(stored: inout [Int: T], new: [Int: T], replaceExisting: Bool = true) { - guard !new.isEmpty else { return } - stored = stored.merging(new, uniquingKeysWith: { stored, new in replaceExisting ? new : stored }) - } - mutating func updatePreviewURLs(_ previewURLs: [Int: URL]) { - update(stored: &self.previewURLs, new: previewURLs) - } - mutating func updateThumbnailURLs(_ thumbnailURLs: [Int: URL]) { - update(stored: &self.thumbnailURLs, new: thumbnailURLs) - } - mutating func updateImageURLs(_ imageURLs: [Int: URL], _ originalImageURLs: [Int: URL]) { - update(stored: &self.imageURLs, new: imageURLs) - update(stored: &self.originalImageURLs, new: originalImageURLs) - } - - // Image - func containerDataSource(setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape) -> [Int] { - let defaultData = Array(1...gallery.pageCount) - guard isLandscape && setting.enablesDualPageMode - && setting.readingDirection != .vertical - else { return defaultData } - - let data = setting.exceptCover - ? [1] + Array(stride(from: 2, through: gallery.pageCount, by: 2)) - : Array(stride(from: 1, through: gallery.pageCount, by: 2)) - - return data - } - func imageContainerConfigs( - index: Int, setting: Setting, isLandscape: Bool = DeviceUtil.isLandscape - ) -> ImageStackConfig { - let direction = setting.readingDirection - let isReversed = direction == .rightToLeft - let isFirstSingle = setting.exceptCover - let isFirstPageAndSingle = index == 1 && isFirstSingle - let isDualPage = isLandscape && setting.enablesDualPageMode && direction != .vertical - let firstIndex = isDualPage && isReversed && !isFirstPageAndSingle ? index + 1 : index - let secondIndex = firstIndex + (isReversed ? -1 : 1) - let isValidFirstRange = firstIndex >= 1 && firstIndex <= gallery.pageCount - let isValidSecondRange = isFirstSingle - ? secondIndex >= 2 && secondIndex <= gallery.pageCount - : secondIndex >= 1 && secondIndex <= gallery.pageCount - return .init( - firstIndex: firstIndex, secondIndex: secondIndex, isFirstAvailable: isValidFirstRange, - isSecondAvailable: !isFirstPageAndSingle && isValidSecondRange && isDualPage - ) - } -} - -enum ReadingAction: BindableAction { - case binding(BindingAction) - case setNavigation(ReadingState.Route?) - - case toggleShowsPanel - case setOrientationPortrait(Bool) - case onPerformDismiss - case onAppear(String, Bool) - - case onWebImageRetry(Int) - case onWebImageSucceeded(Int) - case onWebImageFailed(Int) - case reloadAllWebImages - case retryAllFailedWebImages - - case copyImage(URL) - case saveImage(URL) - case saveImageDone(Bool) - case shareImage(URL) - case fetchImage(ReadingState.ImageAction, URL) - case fetchImageDone(ReadingState.ImageAction, Result) - - case syncReadingProgress(Int) - case syncPreviewURLs([Int: URL]) - case syncThumbnailURLs([Int: URL]) - case syncImageURLs([Int: URL], [Int: URL]) - - case teardown - case fetchDatabaseInfos(String) - case fetchDatabaseInfosDone(GalleryState) - - case fetchPreviewURLs(Int) - case fetchPreviewURLsDone(Int, Result<[Int: URL], AppError>) - - case fetchImageURLs(Int) - case refetchImageURLs(Int) - case prefetchImages(Int, Int) - - case fetchThumbnailURLs(Int) - case fetchThumbnailURLsDone(Int, Result<[Int: URL], AppError>) - case fetchNormalImageURLs(Int, [Int: URL]) - case fetchNormalImageURLsDone(Int, Result<([Int: URL], [Int: URL]), AppError>) - case refetchNormalImageURLs(Int) - case refetchNormalImageURLsDone(Int, Result<([Int: URL], HTTPURLResponse?), AppError>) - - case fetchMPVKeys(Int, URL) - case fetchMPVKeysDone(Int, Result<(String, [Int: String]), AppError>) - case fetchMPVImageURL(Int, Bool) - case fetchMPVImageURLDone(Int, Result<(URL, URL?, String), AppError>) -} - -struct ReadingEnvironment { - let urlClient: URLClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient -} - -let readingReducer = Reducer { state, action, environment in - switch action { - case .binding(\.$showsSliderPreview): - return environment.hapticClient.generateFeedback(.soft).fireAndForget() - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return .none - - case .toggleShowsPanel: - state.showsPanel.toggle() - return .none - - case .setOrientationPortrait(let isPortrait): - var effects = [Effect]() - if isPortrait { - effects.append(environment.appDelegateClient.setPortraitOrientationMask().fireAndForget()) - effects.append(environment.appDelegateClient.setPortraitOrientation().fireAndForget()) - } else { - effects.append(environment.appDelegateClient.setAllOrientationMask().fireAndForget()) - } - return .merge(effects) - - case .onPerformDismiss: - return environment.hapticClient.generateFeedback(.light).fireAndForget() - - case .onAppear(let gid, let enablesLandscape): - var effects: [Effect] = [ - .init(value: .fetchDatabaseInfos(gid)) - ] - if enablesLandscape { - effects.append(.init(value: .setOrientationPortrait(false))) - } - return .merge(effects) - - case .onWebImageRetry(let index): - state.imageURLLoadingStates[index] = .idle - return .none - - case .onWebImageSucceeded(let index): - state.imageURLLoadingStates[index] = .idle - state.webImageLoadSuccessIndices.insert(index) - return .none - - case .onWebImageFailed(let index): - state.imageURLLoadingStates[index] = .failed(.webImageFailed) - return .none - - case .reloadAllWebImages: - state.previewURLs = .init() - state.thumbnailURLs = .init() - state.imageURLs = .init() - state.originalImageURLs = .init() - state.mpvKey = nil - state.mpvImageKeys = .init() - state.mpvSkipServerIdentifiers = .init() - state.forceRefreshID = .init() - return environment.databaseClient.removeImageURLs(gid: state.gallery.id).fireAndForget() - - case .retryAllFailedWebImages: - state.imageURLLoadingStates.forEach { (index, loadingState) in - if case .failed = loadingState { - state.imageURLLoadingStates[index] = .idle - } - } - state.previewLoadingStates.forEach { (index, loadingState) in - if case .failed = loadingState { - state.previewLoadingStates[index] = .idle - } - } - return .none - - case .copyImage(let imageURL): - return .init(value: .fetchImage(.copy(imageURL.isGIF), imageURL)) - - case .saveImage(let imageURL): - return .init(value: .fetchImage(.save(imageURL.isGIF), imageURL)) - - case .saveImageDone(let isSucceeded): - state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error - return .init(value: .setNavigation(.hud)) - - case .shareImage(let imageURL): - return .init(value: .fetchImage(.share(imageURL.isGIF), imageURL)) - - case .fetchImage(let action, let imageURL): - return environment.imageClient.fetchImage(url: imageURL) - .map({ ReadingAction.fetchImageDone(action, $0) }) - .cancellable(id: ReadingState.CancelID()) - - case .fetchImageDone(let action, let result): - if case .success(let image) = result { - switch action { - case .copy(let isAnimated): - state.hudConfig = .copiedToClipboardSucceeded - return .merge( - .init(value: .setNavigation(.hud)), - environment.clipboardClient.saveImage(image, isAnimated).fireAndForget() - ) - case .save(let isAnimated): - return environment.imageClient - .saveImageToPhotoLibrary(image, isAnimated).map(ReadingAction.saveImageDone) - case .share(let isAnimated): - if isAnimated, let data = image.kf.data(format: .GIF) { - return .init(value: .setNavigation(.share(.data(data)))) - } else { - return .init(value: .setNavigation(.share(.image(image)))) - } - } - } else { - state.hudConfig = .error - return .init(value: .setNavigation(.hud)) - } - - case .syncReadingProgress(let progress): - return environment.databaseClient - .updateReadingProgress(gid: state.gallery.id, progress: progress).fireAndForget() - - case .syncPreviewURLs(let previewURLs): - return environment.databaseClient - .updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs).fireAndForget() - - case .syncThumbnailURLs(let thumbnailURLs): - return environment.databaseClient - .updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs).fireAndForget() - - case .syncImageURLs(let imageURLs, let originalImageURLs): - return environment.databaseClient - .updateImageURLs(gid: state.gallery.id, imageURLs: imageURLs, originalImageURLs: originalImageURLs) - .fireAndForget() - - case .teardown: - var effects: [Effect] = [ - .cancel(id: ReadingState.CancelID()) - ] - if !environment.deviceClient.isPad() { - effects.append(.init(value: .setOrientationPortrait(true))) - } - return .merge(effects) - - case .fetchDatabaseInfos(let gid): - guard let gallery = environment.databaseClient.fetchGallery(gid: gid) else { return .none } - state.gallery = gallery - state.galleryDetail = environment.databaseClient.fetchGalleryDetail(gid: state.gallery.id) - return environment.databaseClient.fetchGalleryState(gid: state.gallery.id) - .map(ReadingAction.fetchDatabaseInfosDone).cancellable(id: ReadingState.CancelID()) - - case .fetchDatabaseInfosDone(let galleryState): - if let previewConfig = galleryState.previewConfig { - state.previewConfig = previewConfig - } - state.previewURLs = galleryState.previewURLs - state.imageURLs = galleryState.imageURLs - state.thumbnailURLs = galleryState.thumbnailURLs - state.originalImageURLs = galleryState.originalImageURLs - state.readingProgress = galleryState.readingProgress - state.databaseLoadingState = .idle - return .none - - case .fetchPreviewURLs(let index): - guard state.previewLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.previewLoadingStates[index] = .loading - let pageNum = state.previewConfig.pageNumber(index: index) - return GalleryPreviewURLsRequest(galleryURL: galleryURL, pageNum: pageNum) - .effect.map({ ReadingAction.fetchPreviewURLsDone(index, $0) }).cancellable(id: ReadingState.CancelID()) - - case .fetchPreviewURLsDone(let index, let result): - switch result { - case .success(let previewURLs): - guard !previewURLs.isEmpty else { - state.previewLoadingStates[index] = .failed(.notFound) - return .none - } - state.previewLoadingStates[index] = .idle - state.updatePreviewURLs(previewURLs) - return .init(value: .syncPreviewURLs(previewURLs)) - case .failure(let error): - state.previewLoadingStates[index] = .failed(error) - } - return .none - - case .fetchImageURLs(let index): - if state.mpvKey != nil { - return .init(value: .fetchMPVImageURL(index, false)) - } else { - return .init(value: .fetchThumbnailURLs(index)) - } - - case .refetchImageURLs(let index): - if state.mpvKey != nil { - return .init(value: .fetchMPVImageURL(index, true)) - } else { - return .init(value: .refetchNormalImageURLs(index)) - } - - case .prefetchImages(let index, let prefetchLimit): - func getPrefetchImageURLs(range: ClosedRange) -> [URL] { - (range.lowerBound...range.upperBound).compactMap { index in - if let url = state.imageURLs[index] { - return url - } - return nil - } - } - func getFetchImageURLIndices(range: ClosedRange) -> [Int] { - (range.lowerBound...range.upperBound).compactMap { index in - if state.imageURLs[index] == nil, state.imageURLLoadingStates[index] != .loading { - return index - } - return nil - } - } - var prefetchImageURLs = [URL]() - var fetchImageURLIndices = [Int]() - var effects = [Effect]() - let previousUpperBound = max(index - 2, 1) - let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) - if previousUpperBound - previousLowerBound > 0 { - prefetchImageURLs += getPrefetchImageURLs(range: previousLowerBound...previousUpperBound) - fetchImageURLIndices += getFetchImageURLIndices(range: previousLowerBound...previousUpperBound) - } - let nextLowerBound = min(index + 2, state.gallery.pageCount) - let nextUpperBound = min(nextLowerBound + prefetchLimit / 2, state.gallery.pageCount) - if nextUpperBound - nextLowerBound > 0 { - prefetchImageURLs += getPrefetchImageURLs(range: nextLowerBound...nextUpperBound) - fetchImageURLIndices += getFetchImageURLIndices(range: nextLowerBound...nextUpperBound) - } - fetchImageURLIndices.forEach { - effects.append(.init(value: .fetchImageURLs($0))) - } - effects.append(environment.imageClient.prefetchImages(prefetchImageURLs).fireAndForget()) - return .merge(effects) - - case .fetchThumbnailURLs(let index): - guard state.imageURLLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL - else { return .none } - state.previewConfig.batchRange(index: index).forEach { - state.imageURLLoadingStates[$0] = .loading - } - let pageNum = state.previewConfig.pageNumber(index: index) - return ThumbnailURLsRequest(galleryURL: galleryURL, pageNum: pageNum) - .effect.map({ ReadingAction.fetchThumbnailURLsDone(index, $0) }).cancellable(id: ReadingState.CancelID()) - - case .fetchThumbnailURLsDone(let index, let result): - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let thumbnailURLs): - guard !thumbnailURLs.isEmpty else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) - } - return .none - } - if let url = thumbnailURLs[index], environment.urlClient.checkIfMPVURL(url) { - return .init(value: .fetchMPVKeys(index, url)) - } else { - state.updateThumbnailURLs(thumbnailURLs) - return .merge( - .init(value: .syncThumbnailURLs(thumbnailURLs)), - .init(value: .fetchNormalImageURLs(index, thumbnailURLs)) - ) - } - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - - case .fetchNormalImageURLs(let index, let thumbnailURLs): - return GalleryNormalImageURLsRequest(thumbnailURLs: thumbnailURLs) - .effect.map({ ReadingAction.fetchNormalImageURLsDone(index, $0) }).cancellable(id: ReadingState.CancelID()) - - case .fetchNormalImageURLsDone(let index, let result): - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let (imageURLs, originalImageURLs)): - guard !imageURLs.isEmpty else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) - } - return .none - } - batchRange.forEach { - state.imageURLLoadingStates[$0] = .idle - } - state.updateImageURLs(imageURLs, originalImageURLs) - return .init(value: .syncImageURLs(imageURLs, originalImageURLs)) - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - - case .refetchNormalImageURLs(let index): - guard state.imageURLLoadingStates[index] != .loading, - let galleryURL = state.gallery.galleryURL, - let imageURL = state.imageURLs[index] - else { return .none } - state.imageURLLoadingStates[index] = .loading - let pageNum = state.previewConfig.pageNumber(index: index) - return GalleryNormalImageURLRefetchRequest( - index: index, pageNum: pageNum, - galleryURL: galleryURL, - thumbnailURL: state.thumbnailURLs[index], - storedImageURL: imageURL - ) - .effect.map({ ReadingAction.refetchNormalImageURLsDone(index, $0) }).cancellable(id: ReadingState.CancelID()) - - case .refetchNormalImageURLsDone(let index, let result): - switch result { - case .success(let (imageURLs, response)): - var effects = [Effect]() - if let response = response { - effects.append(environment.cookiesClient.setSkipServer(response: response).fireAndForget()) - } - guard !imageURLs.isEmpty else { - state.imageURLLoadingStates[index] = .failed(.notFound) - return effects.isEmpty ? .none : .merge(effects) - } - state.imageURLLoadingStates[index] = .idle - state.updateImageURLs(imageURLs, [:]) - effects.append(.init(value: .syncImageURLs(imageURLs, [:]))) - return .merge(effects) - case .failure(let error): - state.imageURLLoadingStates[index] = .failed(error) - } - return .none - - case .fetchMPVKeys(let index, let mpvURL): - return MPVKeysRequest(mpvURL: mpvURL) - .effect.map({ ReadingAction.fetchMPVKeysDone(index, $0) }).cancellable(id: ReadingState.CancelID()) - - case .fetchMPVKeysDone(let index, let result): - let batchRange = state.previewConfig.batchRange(index: index) - switch result { - case .success(let (mpvKey, mpvImageKeys)): - let pageCount = state.gallery.pageCount - guard mpvImageKeys.count == pageCount else { - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(.notFound) - } - return .none - } - batchRange.forEach { - state.imageURLLoadingStates[$0] = .idle - } - state.mpvKey = mpvKey - state.mpvImageKeys = mpvImageKeys - return .merge( - Array(1...min(3, max(1, pageCount))).map { - .init(value: .fetchMPVImageURL($0, false)) - } - ) - case .failure(let error): - batchRange.forEach { - state.imageURLLoadingStates[$0] = .failed(error) - } - } - return .none - - case .fetchMPVImageURL(let index, let isRefresh): - guard let gidInteger = Int(state.gallery.id), let mpvKey = state.mpvKey, - let mpvImageKey = state.mpvImageKeys[index], - state.imageURLLoadingStates[index] != .loading - else { return .none } - state.imageURLLoadingStates[index] = .loading - let skipServerIdentifier = isRefresh ? state.mpvSkipServerIdentifiers[index] : nil - return GalleryMPVImageURLRequest( - gid: gidInteger, index: index, mpvKey: mpvKey, - mpvImageKey: mpvImageKey, skipServerIdentifier: skipServerIdentifier - ) - .effect.map({ ReadingAction.fetchMPVImageURLDone(index, $0) }).cancellable(id: ReadingState.CancelID()) - - case .fetchMPVImageURLDone(let index, let result): - switch result { - case .success(let (imageURL, originalImageURL, skipServerIdentifier)): - let imageURLs: [Int: URL] = [index: imageURL] - var originalImageURLs = [Int: URL]() - if let originalImageURL = originalImageURL { - originalImageURLs[index] = originalImageURL - } - state.imageURLLoadingStates[index] = .idle - state.mpvSkipServerIdentifiers[index] = skipServerIdentifier - state.updateImageURLs(imageURLs, originalImageURLs) - return .init(value: .syncImageURLs(imageURLs, originalImageURLs)) - case .failure(let error): - state.imageURLLoadingStates[index] = .failed(error) - } - return .none - } -} -.haptics( - unwrapping: \.route, - case: /ReadingState.Route.readingSetting, - hapticClient: \.hapticClient -) -.haptics( - unwrapping: \.route, - case: /ReadingState.Route.share, - hapticClient: \.hapticClient -) -.binding() diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index c4d2f99e..cb361eca 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -13,8 +13,8 @@ import ComposableArchitecture struct ReadingView: View { @Environment(\.colorScheme) private var colorScheme - let store: Store - @ObservedObject private var viewStore: ViewStore + let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let gid: String @Binding private var setting: Setting private let blurRadius: Double @@ -26,7 +26,7 @@ struct ReadingView: View { @StateObject private var page: Page = .first() init( - store: Store, + store: StoreOf, gid: String, setting: Binding, blurRadius: Double ) { self.store = store @@ -57,7 +57,7 @@ struct ReadingView: View { page: page, data: viewStore.state.containerDataSource(setting: setting), id: \.self, content: imageStack ) - .horizontal(setting.readingDirection == .rightToLeft ? .rightToLeft : .leftToRight) + .horizontal(setting.readingDirection == .rightToLeft ? .endToStart : .startToEnd) .swipeInteractionArea(.allAvailable).allowsDragging(gestureHandler.scale == 1) } } @@ -81,7 +81,7 @@ struct ReadingView: View { fetchPreviewURLsAction: { viewStore.send(.fetchPreviewURLs($0)) } ) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /ReadingState.Route.readingSetting) { _ in + .sheet(unwrapping: viewStore.binding(\.$route), case: /ReadingReducer.Route.readingSetting) { _ in NavigationView { ReadingSettingView( readingDirection: $setting.readingDirection, @@ -106,14 +106,14 @@ struct ReadingView: View { .accentColor(setting.accentColor).tint(setting.accentColor) .autoBlur(radius: blurRadius).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /ReadingState.Route.share) { route in + .sheet(unwrapping: viewStore.binding(\.$route), case: /ReadingReducer.Route.share) { route in ActivityView(activityItems: [route.wrappedValue.associatedValue]) .accentColor(setting.accentColor).autoBlur(radius: blurRadius) } .progressHUD( config: viewStore.hudConfig, unwrapping: viewStore.binding(\.$route), - case: /ReadingState.Route.hud + case: /ReadingReducer.Route.hud ) // Page @@ -246,7 +246,14 @@ extension ReadingView { Logger.info("analyzeImageForLiveText image not found", context: ["index": index]) } case .failure(let error): - Logger.info("analyzeImageForLiveText failed", context: ["index": index, "error": error]) + Logger.info( + "analyzeImageForLiveText failed", + context: [ + "index": index, + "error": error + ] + as [String: Any] + ) } } } @@ -593,17 +600,7 @@ struct ReadingView_Previews: PreviewProvider { ReadingView( store: .init( initialState: .init(gallery: .empty), - reducer: readingReducer, - environment: ReadingEnvironment( - urlClient: .live, - imageClient: .live, - deviceClient: .live, - hapticClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live - ) + reducer: ReadingReducer() ), gid: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Search/SearchReducer.swift b/EhPanda/View/Search/SearchReducer.swift new file mode 100644 index 00000000..f4ea5499 --- /dev/null +++ b/EhPanda/View/Search/SearchReducer.swift @@ -0,0 +1,189 @@ +// +// SearchReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/12. +// + +import ComposableArchitecture + +struct SearchReducer: ReducerProtocol { + enum Route: Equatable { + case filters + case quickSearch + case detail(String) + } + + private enum CancelID: CaseIterable { + case fetchGalleries, fetchMoreGalleries + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var keyword = "" + var lastKeyword = "" + + var galleries = [Gallery]() + var pageNumber = PageNumber() + var loadingState: LoadingState = .idle + var footerLoadingState: LoadingState = .idle + + var filtersState = FiltersReducer.State() + @Heap var detailState: DetailReducer.State! + var quickSearchState = QuickSearchReducer.State() + + init() { + _detailState = .init(.init()) + } + + mutating func insertGalleries(_ galleries: [Gallery]) { + galleries.forEach { gallery in + if !self.galleries.contains(gallery) { + self.galleries.append(gallery) + } + } + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case clearSubStates + + case teardown + case fetchGalleries(String? = nil) + case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + case fetchMoreGalleries + case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) + + case detail(DetailReducer.Action) + case filters(FiltersReducer.Action) + case quickSearch(QuickSearchReducer.Action) + } + + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.hapticsClient) private var hapticsClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding(\.$keyword): + if !state.keyword.isEmpty { + state.lastKeyword = state.keyword + } + return .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.detailState = .init() + state.filtersState = .init() + state.quickSearchState = .init() + return .merge( + .init(value: .detail(.teardown)), + .init(value: .quickSearch(.teardown)) + ) + + case .teardown: + return .cancel(ids: CancelID.allCases) + + case .fetchGalleries(let keyword): + guard state.loadingState != .loading else { return .none } + if let keyword = keyword { + state.keyword = keyword + state.lastKeyword = keyword + } + state.loadingState = .loading + state.pageNumber.resetPages() + let filter = databaseClient.fetchFilterSynchronously(range: .search) + return SearchGalleriesRequest(keyword: state.lastKeyword, filter: filter).effect + .map(Action.fetchGalleriesDone) + .cancellable(id: CancelID.fetchGalleries) + + case .fetchGalleriesDone(let result): + state.loadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + guard !galleries.isEmpty else { + state.loadingState = .failed(.notFound) + guard pageNumber.hasNextPage() else { return .none } + return .init(value: .fetchMoreGalleries) + } + state.pageNumber = pageNumber + state.galleries = galleries + return databaseClient.cacheGalleries(galleries).fireAndForget() + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .fetchMoreGalleries: + let pageNumber = state.pageNumber + guard pageNumber.hasNextPage(), + state.footerLoadingState != .loading, + let lastID = state.galleries.last?.id + else { return .none } + state.footerLoadingState = .loading + let filter = databaseClient.fetchFilterSynchronously(range: .search) + return MoreSearchGalleriesRequest(keyword: state.lastKeyword, filter: filter, lastID: lastID).effect + .map(Action.fetchMoreGalleriesDone) + .cancellable(id: CancelID.fetchMoreGalleries) + + case .fetchMoreGalleriesDone(let result): + state.footerLoadingState = .idle + switch result { + case .success(let (pageNumber, galleries)): + state.pageNumber = pageNumber + state.insertGalleries(galleries) + + var effects: [EffectTask] = [ + databaseClient.cacheGalleries(galleries).fireAndForget() + ] + if galleries.isEmpty, pageNumber.hasNextPage() { + effects.append(.init(value: .fetchMoreGalleries)) + } else if !galleries.isEmpty { + state.loadingState = .idle + } + return .merge(effects) + + case .failure(let error): + state.footerLoadingState = .failed(error) + } + return .none + + case .detail: + return .none + + case .filters: + return .none + + case .quickSearch: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /Route.quickSearch, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.route, + case: /Route.filters, + hapticsClient: hapticsClient + ) + + Scope(state: \.filtersState, action: /Action.filters, child: FiltersReducer.init) + Scope(state: \.quickSearchState, action: /Action.quickSearch, child: QuickSearchReducer.init) + Scope(state: \.detailState, action: /Action.detail, child: DetailReducer.init) + } +} diff --git a/EhPanda/View/Search/SearchRootReducer.swift b/EhPanda/View/Search/SearchRootReducer.swift new file mode 100644 index 00000000..a3e23634 --- /dev/null +++ b/EhPanda/View/Search/SearchRootReducer.swift @@ -0,0 +1,192 @@ +// +// SearchRootReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import ComposableArchitecture + +struct SearchRootReducer: ReducerProtocol { + enum Route: Equatable { + case search + case filters + case quickSearch + case detail(String) + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var keyword = "" + var historyGalleries = [Gallery]() + + var historyKeywords = [String]() + var quickSearchWords = [QuickSearchWord]() + + var searchState = SearchReducer.State() + var filtersState = FiltersReducer.State() + var quickSearchState = QuickSearchReducer.State() + @Heap var detailState: DetailReducer.State! + + init() { + _detailState = .init(.init()) + } + + mutating func appendHistoryKeywords(_ keywords: [String]) { + guard !keywords.isEmpty else { return } + var historyKeywords = historyKeywords + + keywords.forEach { keyword in + guard !keyword.isEmpty else { return } + if let index = historyKeywords.firstIndex(where: { + $0.caseInsensitiveEqualsTo(keyword) + }) { + if historyKeywords.last != keyword { + historyKeywords.remove(at: index) + historyKeywords.append(keyword) + } + } else { + historyKeywords.append(keyword) + let overflow = historyKeywords.count - 20 + if overflow > 0 { + historyKeywords = Array( + historyKeywords.dropFirst(overflow) + ) + } + } + } + self.historyKeywords = historyKeywords + } + + mutating func removeHistoryKeyword(_ keyword: String) { + historyKeywords = historyKeywords.filter { $0 != keyword } + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case setNavigation(Route?) + case setKeyword(String) + case clearSubStates + + case syncHistoryKeywords + case fetchDatabaseInfos + case fetchDatabaseInfosDone(AppEnv) + case appendHistoryKeyword(String) + case removeHistoryKeyword(String) + case fetchHistoryGalleries + case fetchHistoryGalleriesDone([Gallery]) + + case search(SearchReducer.Action) + case filters(FiltersReducer.Action) + case quickSearch(QuickSearchReducer.Action) + case detail(DetailReducer.Action) + } + + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.hapticsClient) private var hapticsClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil + ? .merge( + .init(value: .clearSubStates), + .init(value: .fetchDatabaseInfos) + ) + : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil + ? .merge( + .init(value: .clearSubStates), + .init(value: .fetchDatabaseInfos) + ) + : .none + + case .setKeyword(let keyword): + state.keyword = keyword + return .none + + case .clearSubStates: + state.searchState = .init() + state.detailState = .init() + state.filtersState = .init() + state.quickSearchState = .init() + return .merge( + .init(value: .search(.teardown)), + .init(value: .quickSearch(.teardown)), + .init(value: .detail(.teardown)) + ) + + case .syncHistoryKeywords: + return databaseClient.updateHistoryKeywords(state.historyKeywords).fireAndForget() + + case .fetchDatabaseInfos: + return databaseClient.fetchAppEnv().map(Action.fetchDatabaseInfosDone) + + case .fetchDatabaseInfosDone(let appEnv): + state.historyKeywords = appEnv.historyKeywords + state.quickSearchWords = appEnv.quickSearchWords + return .none + + case .appendHistoryKeyword(let keyword): + state.appendHistoryKeywords([keyword]) + return .init(value: .syncHistoryKeywords) + + case .removeHistoryKeyword(let keyword): + state.removeHistoryKeyword(keyword) + return .init(value: .syncHistoryKeywords) + + case .fetchHistoryGalleries: + return databaseClient.fetchHistoryGalleries(fetchLimit: 10).map(Action.fetchHistoryGalleriesDone) + + case .fetchHistoryGalleriesDone(let galleries): + state.historyGalleries = Array(galleries.prefix(min(galleries.count, 10))) + return .none + + case .search(.fetchGalleries(let keyword)): + if let keyword = keyword { + state.appendHistoryKeywords([keyword]) + } else { + state.appendHistoryKeywords([state.searchState.lastKeyword]) + } + return .init(value: .syncHistoryKeywords) + + case .search: + return .none + + case .filters: + return .none + + case .quickSearch: + return .none + + case .detail: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /Route.quickSearch, + hapticsClient: hapticsClient + ) + .haptics( + unwrapping: \.route, + case: /Route.filters, + hapticsClient: hapticsClient + ) + + Scope(state: \.searchState, action: /Action.search, child: SearchReducer.init) + Scope(state: \.filtersState, action: /Action.filters, child: FiltersReducer.init) + Scope(state: \.quickSearchState, action: /Action.quickSearch, child: QuickSearchReducer.init) + Scope(state: \.detailState, action: /Action.detail, child: DetailReducer.init) + } +} diff --git a/EhPanda/View/Search/SearchRootStore.swift b/EhPanda/View/Search/SearchRootStore.swift deleted file mode 100644 index aeb71a50..00000000 --- a/EhPanda/View/Search/SearchRootStore.swift +++ /dev/null @@ -1,248 +0,0 @@ -// -// SearchRootStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/09. -// - -import ComposableArchitecture - -struct SearchRootState: Equatable { - enum Route: Equatable { - case search - case filters - case quickSearch - case detail(String) - } - - init() { - _detailState = .init(.init()) - } - - @BindableState var route: Route? - @BindableState var keyword = "" - var historyGalleries = [Gallery]() - - var historyKeywords = [String]() - var quickSearchWords = [QuickSearchWord]() - - var searchState = SearchState() - var filtersState = FiltersState() - var quickSearchState = QuickSearchState() - @Heap var detailState: DetailState! - - mutating func appendHistoryKeywords(_ keywords: [String]) { - guard !keywords.isEmpty else { return } - var historyKeywords = historyKeywords - - keywords.forEach { keyword in - guard !keyword.isEmpty else { return } - if let index = historyKeywords.firstIndex(where: { - $0.caseInsensitiveEqualsTo(keyword) - }) { - if historyKeywords.last != keyword { - historyKeywords.remove(at: index) - historyKeywords.append(keyword) - } - } else { - historyKeywords.append(keyword) - let overflow = historyKeywords.count - 20 - if overflow > 0 { - historyKeywords = Array( - historyKeywords.dropFirst(overflow) - ) - } - } - } - self.historyKeywords = historyKeywords - } - mutating func removeHistoryKeyword(_ keyword: String) { - historyKeywords = historyKeywords.filter { $0 != keyword } - } -} - -enum SearchRootAction: BindableAction { - case binding(BindingAction) - case setNavigation(SearchRootState.Route?) - case setKeyword(String) - case clearSubStates - - case syncHistoryKeywords - case fetchDatabaseInfos - case fetchDatabaseInfosDone(AppEnv) - case appendHistoryKeyword(String) - case removeHistoryKeyword(String) - case fetchHistoryGalleries - case fetchHistoryGalleriesDone([Gallery]) - - case search(SearchAction) - case filters(FiltersAction) - case quickSearch(QuickSearchAction) - case detail(DetailAction) -} - -struct SearchRootEnvironment { - let urlClient: URLClient - let fileClient: FileClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient - let uiApplicationClient: UIApplicationClient -} - -let searchRootReducer = Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil - ? .merge( - .init(value: .clearSubStates), - .init(value: .fetchDatabaseInfos) - ) - : .none - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil - ? .merge( - .init(value: .clearSubStates), - .init(value: .fetchDatabaseInfos) - ) - : .none - - case .setKeyword(let keyword): - state.keyword = keyword - return .none - - case .clearSubStates: - state.searchState = .init() - state.detailState = .init() - state.filtersState = .init() - state.quickSearchState = .init() - return .merge( - .init(value: .search(.teardown)), - .init(value: .quickSearch(.teardown)), - .init(value: .detail(.teardown)) - ) - - case .syncHistoryKeywords: - return environment.databaseClient.updateHistoryKeywords(state.historyKeywords).fireAndForget() - - case .fetchDatabaseInfos: - return environment.databaseClient.fetchAppEnv().map(SearchRootAction.fetchDatabaseInfosDone) - - case .fetchDatabaseInfosDone(let appEnv): - state.historyKeywords = appEnv.historyKeywords - state.quickSearchWords = appEnv.quickSearchWords - return .none - - case .appendHistoryKeyword(let keyword): - state.appendHistoryKeywords([keyword]) - return .init(value: .syncHistoryKeywords) - - case .removeHistoryKeyword(let keyword): - state.removeHistoryKeyword(keyword) - return .init(value: .syncHistoryKeywords) - - case .fetchHistoryGalleries: - return environment.databaseClient - .fetchHistoryGalleries(fetchLimit: 10).map(SearchRootAction.fetchHistoryGalleriesDone) - - case .fetchHistoryGalleriesDone(let galleries): - state.historyGalleries = Array(galleries.prefix(min(galleries.count, 10))) - return .none - - case .search(.fetchGalleries(let keyword)): - if let keyword = keyword { - state.appendHistoryKeywords([keyword]) - } else { - state.appendHistoryKeywords([state.searchState.lastKeyword]) - } - return .init(value: .syncHistoryKeywords) - - case .search: - return .none - - case .filters: - return .none - - case .quickSearch: - return .none - - case .detail: - return .none - } - } - .haptics( - unwrapping: \.route, - case: /SearchRootState.Route.quickSearch, - hapticClient: \.hapticClient - ) - .haptics( - unwrapping: \.route, - case: /SearchRootState.Route.filters, - hapticClient: \.hapticClient - ) - .binding(), - searchReducer.pullback( - state: \.searchState, - action: /SearchRootAction.search, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ), - filtersReducer.pullback( - state: \.filtersState, - action: /SearchRootAction.filters, - environment: { - .init( - databaseClient: $0.databaseClient - ) - } - ), - quickSearchReducer.pullback( - state: \.quickSearchState, - action: /SearchRootAction.quickSearch, - environment: { - .init( - databaseClient: $0.databaseClient - ) - } - ), - detailReducer.pullback( - state: \.detailState, - action: /SearchRootAction.detail, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ) -) diff --git a/EhPanda/View/Search/SearchRootView.swift b/EhPanda/View/Search/SearchRootView.swift index 0288f172..52e0a7f0 100644 --- a/EhPanda/View/Search/SearchRootView.swift +++ b/EhPanda/View/Search/SearchRootView.swift @@ -9,15 +9,15 @@ import SwiftUI import ComposableArchitecture struct SearchRootView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let user: User @Binding private var setting: Setting private let blurRadius: Double private let tagTranslator: TagTranslator init( - store: Store, + store: StoreOf, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store @@ -28,10 +28,6 @@ struct SearchRootView: View { self.tagTranslator = tagTranslator } - private var searchFieldPlacement: SearchFieldPlacement { - DeviceUtil.isPad ? .toolbar : .navigationBarDrawer(displayMode: .always) - } - var body: some View { NavigationView { ScrollView(showsIndicators: false) { @@ -50,25 +46,25 @@ struct SearchRootView: View { } .sheet( unwrapping: viewStore.binding(\.$route), - case: /SearchRootState.Route.detail, + case: /SearchRootReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in NavigationView { DetailView( - store: store.scope(state: \.detailState, action: SearchRootAction.detail), + store: store.scope(state: \.detailState, action: SearchRootReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) } .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /SearchRootState.Route.filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: SearchRootAction.filters)) + .sheet(unwrapping: viewStore.binding(\.$route), case: /SearchRootReducer.Route.filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: SearchRootReducer.Action.filters)) .autoBlur(radius: blurRadius).environment(\.inSheet, true) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /SearchRootState.Route.quickSearch) { _ in + .sheet(unwrapping: viewStore.binding(\.$route), case: /SearchRootReducer.Route.quickSearch) { _ in QuickSearchView( - store: store.scope(state: \.quickSearchState, action: SearchRootAction.quickSearch) + store: store.scope(state: \.quickSearchState, action: SearchRootReducer.Action.quickSearch) ) { keyword in viewStore.send(.setNavigation(nil)) viewStore.send(.setKeyword(keyword)) @@ -79,7 +75,8 @@ struct SearchRootView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .searchable(text: viewStore.binding(\.$keyword), placement: searchFieldPlacement) { + .searchable(text: viewStore.binding(\.$keyword)) + .searchSuggestions { TagSuggestionView( keyword: viewStore.binding(\.$keyword), translations: tagTranslator.translations, showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion @@ -120,18 +117,18 @@ private extension SearchRootView { searchViewLink } var detailViewLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SearchRootState.Route.detail) { route in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SearchRootReducer.Route.detail) { route in DetailView( - store: store.scope(state: \.detailState, action: SearchRootAction.detail), + store: store.scope(state: \.detailState, action: SearchRootReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) } } var searchViewLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SearchRootState.Route.search) { _ in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SearchRootReducer.Route.search) { _ in SearchView( - store: store.scope(state: \.searchState, action: SearchRootAction.search), + store: store.scope(state: \.searchState, action: SearchRootReducer.Action.search), keyword: viewStore.keyword, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) @@ -409,19 +406,7 @@ struct SearchRootView_Previews: PreviewProvider { SearchRootView( store: .init( initialState: .init(), - reducer: searchRootReducer, - environment: SearchRootEnvironment( - urlClient: .live, - fileClient: .live, - imageClient: .live, - deviceClient: .live, - hapticClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live, - uiApplicationClient: .live - ) + reducer: SearchRootReducer() ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Search/SearchStore.swift b/EhPanda/View/Search/SearchStore.swift deleted file mode 100644 index e41c327e..00000000 --- a/EhPanda/View/Search/SearchStore.swift +++ /dev/null @@ -1,227 +0,0 @@ -// -// SearchStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/12. -// - -import ComposableArchitecture - -struct SearchState: Equatable { - enum Route: Equatable { - case filters - case quickSearch - case detail(String) - } - struct CancelID: Hashable { - let id = String(describing: SearchState.self) - } - - init() { - _detailState = .init(.init()) - } - - @BindableState var route: Route? - @BindableState var keyword = "" - var lastKeyword = "" - - var galleries = [Gallery]() - var pageNumber = PageNumber() - var loadingState: LoadingState = .idle - var footerLoadingState: LoadingState = .idle - - var filtersState = FiltersState() - @Heap var detailState: DetailState! - var quickSearchState = QuickSearchState() - - mutating func insertGalleries(_ galleries: [Gallery]) { - galleries.forEach { gallery in - if !self.galleries.contains(gallery) { - self.galleries.append(gallery) - } - } - } -} - -enum SearchAction: BindableAction { - case binding(BindingAction) - case setNavigation(SearchState.Route?) - case clearSubStates - - case teardown - case fetchGalleries(String? = nil) - case fetchGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - case fetchMoreGalleries - case fetchMoreGalleriesDone(Result<(PageNumber, [Gallery]), AppError>) - - case detail(DetailAction) - case filters(FiltersAction) - case quickSearch(QuickSearchAction) -} - -struct SearchEnvironment { - let urlClient: URLClient - let fileClient: FileClient - let imageClient: ImageClient - let deviceClient: DeviceClient - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient - let uiApplicationClient: UIApplicationClient -} - -let searchReducer = Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding(\.$keyword): - if !state.keyword.isEmpty { - state.lastKeyword = state.keyword - } - return .none - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .clearSubStates: - state.detailState = .init() - state.filtersState = .init() - state.quickSearchState = .init() - return .merge( - .init(value: .detail(.teardown)), - .init(value: .quickSearch(.teardown)) - ) - - case .teardown: - return .cancel(id: SearchState.CancelID()) - - case .fetchGalleries(let keyword): - guard state.loadingState != .loading else { return .none } - if let keyword = keyword { - state.keyword = keyword - state.lastKeyword = keyword - } - state.loadingState = .loading - state.pageNumber.resetPages() - let filter = environment.databaseClient.fetchFilterSynchronously(range: .search) - return SearchGalleriesRequest(keyword: state.lastKeyword, filter: filter).effect - .map(SearchAction.fetchGalleriesDone) - .cancellable(id: SearchState.CancelID()) - - case .fetchGalleriesDone(let result): - state.loadingState = .idle - switch result { - case .success(let (pageNumber, galleries)): - guard !galleries.isEmpty else { - state.loadingState = .failed(.notFound) - guard pageNumber.hasNextPage() else { return .none } - return .init(value: .fetchMoreGalleries) - } - state.pageNumber = pageNumber - state.galleries = galleries - return environment.databaseClient.cacheGalleries(galleries).fireAndForget() - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .fetchMoreGalleries: - let pageNumber = state.pageNumber - guard pageNumber.hasNextPage(), - state.footerLoadingState != .loading, - let lastID = state.galleries.last?.id - else { return .none } - state.footerLoadingState = .loading - let filter = environment.databaseClient.fetchFilterSynchronously(range: .search) - return MoreSearchGalleriesRequest(keyword: state.lastKeyword, filter: filter, lastID: lastID).effect - .map(SearchAction.fetchMoreGalleriesDone) - .cancellable(id: SearchState.CancelID()) - - case .fetchMoreGalleriesDone(let result): - state.footerLoadingState = .idle - switch result { - case .success(let (pageNumber, galleries)): - state.pageNumber = pageNumber - state.insertGalleries(galleries) - - var effects: [Effect] = [ - environment.databaseClient.cacheGalleries(galleries).fireAndForget() - ] - if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.init(value: .fetchMoreGalleries)) - } else if !galleries.isEmpty { - state.loadingState = .idle - } - return .merge(effects) - - case .failure(let error): - state.footerLoadingState = .failed(error) - } - return .none - - case .detail: - return .none - - case .filters: - return .none - - case .quickSearch: - return .none - } - } - .haptics( - unwrapping: \.route, - case: /SearchState.Route.quickSearch, - hapticClient: \.hapticClient - ) - .haptics( - unwrapping: \.route, - case: /SearchState.Route.filters, - hapticClient: \.hapticClient - ) - .binding(), - filtersReducer.pullback( - state: \.filtersState, - action: /SearchAction.filters, - environment: { - .init( - databaseClient: $0.databaseClient - ) - } - ), - quickSearchReducer.pullback( - state: \.quickSearchState, - action: /SearchAction.quickSearch, - environment: { - .init( - databaseClient: $0.databaseClient - ) - } - ), - detailReducer.pullback( - state: \.detailState, - action: /SearchAction.detail, - environment: { - .init( - urlClient: $0.urlClient, - fileClient: $0.fileClient, - imageClient: $0.imageClient, - deviceClient: $0.deviceClient, - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - databaseClient: $0.databaseClient, - clipboardClient: $0.clipboardClient, - appDelegateClient: $0.appDelegateClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ) -) diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift index ceca4976..9a637652 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -9,8 +9,8 @@ import SwiftUI import ComposableArchitecture struct SearchView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let keyword: String private let user: User @Binding private var setting: Setting @@ -18,7 +18,7 @@ struct SearchView: View { private let tagTranslator: TagTranslator init( - store: Store, + store: StoreOf, keyword: String, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store @@ -46,21 +46,21 @@ struct SearchView: View { ) .sheet( unwrapping: viewStore.binding(\.$route), - case: /SearchState.Route.detail, + case: /SearchReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in NavigationView { DetailView( - store: store.scope(state: \.detailState, action: SearchAction.detail), + store: store.scope(state: \.detailState, action: SearchReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) } .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /SearchState.Route.quickSearch) { _ in + .sheet(unwrapping: viewStore.binding(\.$route), case: /SearchReducer.Route.quickSearch) { _ in QuickSearchView( - store: store.scope(state: \.quickSearchState, action: SearchAction.quickSearch) + store: store.scope(state: \.quickSearchState, action: SearchReducer.Action.quickSearch) ) { keyword in viewStore.send(.setNavigation(nil)) viewStore.send(.fetchGalleries(keyword)) @@ -68,11 +68,12 @@ struct SearchView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /SearchState.Route.filters) { _ in - FiltersView(store: store.scope(state: \.filtersState, action: SearchAction.filters)) + .sheet(unwrapping: viewStore.binding(\.$route), case: /SearchReducer.Route.filters) { _ in + FiltersView(store: store.scope(state: \.filtersState, action: SearchReducer.Action.filters)) .accentColor(setting.accentColor).autoBlur(radius: blurRadius) } - .searchable(text: viewStore.binding(\.$keyword)) { + .searchable(text: viewStore.binding(\.$keyword)) + .searchSuggestions { TagSuggestionView( keyword: viewStore.binding(\.$keyword), translations: tagTranslator.translations, showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion @@ -95,9 +96,9 @@ struct SearchView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SearchState.Route.detail) { route in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SearchReducer.Route.detail) { route in DetailView( - store: store.scope(state: \.detailState, action: SearchAction.detail), + store: store.scope(state: \.detailState, action: SearchReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, blurRadius: blurRadius, tagTranslator: tagTranslator ) @@ -123,19 +124,7 @@ struct SearchView_Previews: PreviewProvider { SearchView( store: .init( initialState: .init(), - reducer: searchReducer, - environment: SearchEnvironment( - urlClient: .live, - fileClient: .live, - imageClient: .live, - deviceClient: .live, - hapticClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live, - uiApplicationClient: .live - ) + reducer: SearchReducer() ), keyword: .init(), user: .init(), diff --git a/EhPanda/View/Search/Support/QuickSearchReducer.swift b/EhPanda/View/Search/Support/QuickSearchReducer.swift new file mode 100644 index 00000000..a1449c90 --- /dev/null +++ b/EhPanda/View/Search/Support/QuickSearchReducer.swift @@ -0,0 +1,134 @@ +// +// QuickSearchReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/20. +// + +import SwiftUI +import ComposableArchitecture + +struct QuickSearchReducer: ReducerProtocol { + enum Route: Equatable { + case newWord + case editWord + case deleteWord(QuickSearchWord) + } + + enum FocusField { + case name + case content + } + + private enum CancelID { + case fetchQuickSearchWords + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var focusedField: FocusField? + @BindingState var editingWord: QuickSearchWord = .empty + @BindingState var listEditMode: EditMode = .inactive + var isListEditing: Bool { + get { listEditMode == .active } + set { listEditMode = newValue ? .active : .inactive } + } + + var loadingState: LoadingState = .idle + var quickSearchWords = [QuickSearchWord]() + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case setNavigation(Route?) + case clearSubStates + + case syncQuickSearchWords + + case toggleListEditing + case setEditingWord(QuickSearchWord) + + case appendWord + case editWord + case deleteWord(QuickSearchWord) + case deleteWordWithOffsets(IndexSet) + case moveWord(IndexSet, Int) + + case teardown + case fetchQuickSearchWords + case fetchQuickSearchWordsDone([QuickSearchWord]) + } + + @Dependency(\.databaseClient) private var databaseClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.focusedField = nil + state.editingWord = .empty + return .none + + case .syncQuickSearchWords: + return databaseClient.updateQuickSearchWords(state.quickSearchWords).fireAndForget() + + case .toggleListEditing: + state.isListEditing.toggle() + return .none + + case .setEditingWord(let word): + state.editingWord = word + return .none + + case .appendWord: + state.quickSearchWords.append(state.editingWord) + return .init(value: .syncQuickSearchWords) + + case .editWord: + if let index = state.quickSearchWords.firstIndex(where: { $0.id == state.editingWord.id }) { + state.quickSearchWords[index] = state.editingWord + return .init(value: .syncQuickSearchWords) + } + return .none + + case .deleteWord(let word): + state.quickSearchWords = state.quickSearchWords.filter({ $0 != word }) + return .init(value: .syncQuickSearchWords) + + case .deleteWordWithOffsets(let offsets): + state.quickSearchWords.remove(atOffsets: offsets) + return .init(value: .syncQuickSearchWords) + + case .moveWord(let source, let destination): + state.quickSearchWords.move(fromOffsets: source, toOffset: destination) + return .init(value: .syncQuickSearchWords) + + case .teardown: + return .cancel(id: CancelID.fetchQuickSearchWords) + + case .fetchQuickSearchWords: + state.loadingState = .loading + return databaseClient.fetchQuickSearchWords() + .map(Action.fetchQuickSearchWordsDone) + .cancellable(id: CancelID.fetchQuickSearchWords) + + case .fetchQuickSearchWordsDone(let words): + state.loadingState = .idle + state.quickSearchWords = words + return .none + } + } + } +} diff --git a/EhPanda/View/Search/Support/QuickSearchStore.swift b/EhPanda/View/Search/Support/QuickSearchStore.swift deleted file mode 100644 index 33de4c30..00000000 --- a/EhPanda/View/Search/Support/QuickSearchStore.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// QuickSearchStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/20. -// - -import SwiftUI -import ComposableArchitecture - -struct QuickSearchState: Equatable { - enum Route: Equatable { - case newWord - case editWord - case deleteWord(QuickSearchWord) - } - enum FocusField { - case name - case content - } - struct CancelID: Hashable { - let id = String(describing: QuickSearchState.self) - } - - @BindableState var route: Route? - @BindableState var focusedField: FocusField? - @BindableState var editingWord: QuickSearchWord = .empty - @BindableState var listEditMode: EditMode = .inactive - var isListEditing: Bool { - get { listEditMode == .active } - set { listEditMode = newValue ? .active : .inactive } - } - - var loadingState: LoadingState = .idle - var quickSearchWords = [QuickSearchWord]() -} - -enum QuickSearchAction: BindableAction { - case binding(BindingAction) - case setNavigation(QuickSearchState.Route?) - case clearSubStates - - case syncQuickSearchWords - - case toggleListEditing - case setEditingWord(QuickSearchWord) - - case appendWord - case editWord - case deleteWord(QuickSearchWord) - case deleteWordWithOffsets(IndexSet) - case moveWord(IndexSet, Int) - - case teardown - case fetchQuickSearchWords - case fetchQuickSearchWordsDone([QuickSearchWord]) -} - -struct QuickSearchEnvironment { - let databaseClient: DatabaseClient -} - -let quickSearchReducer = Reducer -{ state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .clearSubStates: - state.focusedField = nil - state.editingWord = .empty - return .none - - case .syncQuickSearchWords: - return environment.databaseClient.updateQuickSearchWords(state.quickSearchWords).fireAndForget() - - case .toggleListEditing: - state.isListEditing.toggle() - return .none - - case .setEditingWord(let word): - state.editingWord = word - return .none - - case .appendWord: - state.quickSearchWords.append(state.editingWord) - return .init(value: .syncQuickSearchWords) - - case .editWord: - if let index = state.quickSearchWords.firstIndex(where: { $0.id == state.editingWord.id }) { - state.quickSearchWords[index] = state.editingWord - return .init(value: .syncQuickSearchWords) - } - return .none - - case .deleteWord(let word): - state.quickSearchWords = state.quickSearchWords.filter({ $0 != word }) - return .init(value: .syncQuickSearchWords) - - case .deleteWordWithOffsets(let offsets): - state.quickSearchWords.remove(atOffsets: offsets) - return .init(value: .syncQuickSearchWords) - - case .moveWord(let source, let destination): - state.quickSearchWords.move(fromOffsets: source, toOffset: destination) - return .init(value: .syncQuickSearchWords) - - case .teardown: - return .cancel(id: QuickSearchState.CancelID()) - - case .fetchQuickSearchWords: - state.loadingState = .loading - return environment.databaseClient - .fetchQuickSearchWords().map(QuickSearchAction.fetchQuickSearchWordsDone) - .cancellable(id: QuickSearchState.CancelID()) - - case .fetchQuickSearchWordsDone(let words): - state.loadingState = .idle - state.quickSearchWords = words - return .none - } -} -.binding() diff --git a/EhPanda/View/Search/Support/QuickSearchView.swift b/EhPanda/View/Search/Support/QuickSearchView.swift index 26aaa632..62e8e591 100644 --- a/EhPanda/View/Search/Support/QuickSearchView.swift +++ b/EhPanda/View/Search/Support/QuickSearchView.swift @@ -9,13 +9,13 @@ import SwiftUI import ComposableArchitecture struct QuickSearchView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let searchAction: (String) -> Void - @FocusState private var focusedField: QuickSearchState.FocusField? + @FocusState private var focusedField: QuickSearchReducer.FocusField? - init(store: Store, searchAction: @escaping (String) -> Void) { + init(store: StoreOf, searchAction: @escaping (String) -> Void) { self.store = store viewStore = ViewStore(store) self.searchAction = searchAction @@ -55,7 +55,7 @@ struct QuickSearchView: View { .confirmationDialog( message: L10n.Localizable.ConfirmationDialog.Title.delete, unwrapping: viewStore.binding(\.$route), - case: /QuickSearchState.Route.deleteWord, + case: /QuickSearchReducer.Route.deleteWord, matching: word ) { route in Button(L10n.Localizable.ConfirmationDialog.Button.delete, role: .destructive) { @@ -123,7 +123,7 @@ struct QuickSearchView: View { } } @ViewBuilder private var navigationLinks: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /QuickSearchState.Route.newWord) { _ in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /QuickSearchReducer.Route.newWord) { _ in EditWordView( title: L10n.Localizable.QuickSearchView.Title.newWord, word: viewStore.binding(\.$editingWord), @@ -135,7 +135,7 @@ struct QuickSearchView: View { } ) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /QuickSearchState.Route.editWord) { _ in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /QuickSearchReducer.Route.editWord) { _ in EditWordView( title: L10n.Localizable.QuickSearchView.Title.editWord, word: viewStore.binding(\.$editingWord), @@ -155,13 +155,13 @@ extension QuickSearchView { struct EditWordView: View { private let title: String @Binding private var word: QuickSearchWord - private let focusedField: FocusState.Binding + private let focusedField: FocusState.Binding private let submitAction: () -> Void private let confirmAction: () -> Void init( title: String, word: Binding, - focusedField: FocusState.Binding, + focusedField: FocusState.Binding, submitAction: @escaping () -> Void, confirmAction: @escaping () -> Void ) { self.title = title @@ -204,10 +204,7 @@ struct QuickSearchView_Previews: PreviewProvider { QuickSearchView( store: .init( initialState: .init(), - reducer: quickSearchReducer, - environment: QuickSearchEnvironment( - databaseClient: .live - ) + reducer: QuickSearchReducer() ), searchAction: { _ in } ) diff --git a/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift b/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift new file mode 100644 index 00000000..78e025a8 --- /dev/null +++ b/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift @@ -0,0 +1,160 @@ +// +// AccountSettingReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/31. +// + +import Foundation +import TTProgressHUD +import ComposableArchitecture + +struct AccountSettingReducer: ReducerProtocol { + enum Route: Equatable { + case hud + case login + case logout + case ehSetting + case webView(URL) + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var ehCookiesState: CookiesState = .empty(.ehentai) + @BindingState var exCookiesState: CookiesState = .empty(.exhentai) + var hudConfig: TTProgressHUDConfig = .copiedToClipboardSucceeded + + var loginState = LoginReducer.State() + var ehSettingState = EhSettingReducer.State() + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case setNavigation(Route?) + case onLogoutConfirmButtonTapped + case clearSubStates + + case loadCookies + case copyCookies(GalleryHost) + + case login(LoginReducer.Action) + case ehSetting(EhSettingReducer.Action) + } + + @Dependency(\.clipboardClient) private var clipboardClient + @Dependency(\.cookieClient) private var cookieClient + @Dependency(\.hapticsClient) private var hapticsClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding(\.$ehCookiesState): + return cookieClient.setCookies(state: state.ehCookiesState).fireAndForget() + + case .binding(\.$exCookiesState): + return cookieClient.setCookies(state: state.exCookiesState).fireAndForget() + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .onLogoutConfirmButtonTapped: + return .init(value: .loadCookies) + + case .clearSubStates: + state.loginState = .init() + state.ehSettingState = .init() + return .merge( + .init(value: .login(.teardown)), + .init(value: .ehSetting(.teardown)) + ) + + case .loadCookies: + state.ehCookiesState = cookieClient.loadCookiesState(host: .ehentai) + state.exCookiesState = cookieClient.loadCookiesState(host: .exhentai) + return .none + + case .copyCookies(let host): + let cookiesDescription = cookieClient.getCookiesDescription(host: host) + return .merge( + .init(value: .setNavigation(.hud)), + clipboardClient.saveText(cookiesDescription).fireAndForget(), + .fireAndForget({ hapticsClient.generateNotificationFeedback(.success) }) + ) + + case .login(.loginDone): + return cookieClient.didLogin ? .init(value: .setNavigation(nil)) : .none + + case .login: + return .none + + case .ehSetting: + return .none + } + } + .haptics( + unwrapping: \.route, + case: /Route.webView, + hapticsClient: hapticsClient + ) + + Scope(state: \.loginState, action: /Action.login, child: LoginReducer.init) + Scope(state: \.ehSettingState, action: /Action.ehSetting, child: EhSettingReducer.init) + } +} + +// MARK: Models +struct CookieValue: Equatable { + static let empty: Self = .init( + rawValue: .init(), localizedString: .init() + ) + + let rawValue: String + let localizedString: String + + var isInvalid: Bool { + !localizedString.isEmpty && !rawValue.isEmpty + } + var placeholder: String { + localizedString.isEmpty ? rawValue : localizedString + } +} + +struct CookiesState: Equatable { + static func empty(_ host: GalleryHost) -> Self { + .init( + host: host, + igneous: .empty, + memberID: .empty, + passHash: .empty + ) + } + var allCases: [CookieState] {[ + igneous, memberID, passHash + ]} + + let host: GalleryHost + var igneous: CookieState + var memberID: CookieState + var passHash: CookieState +} + +struct CookieState: Equatable { + static let empty: Self = .init( + key: "", value: .init( + rawValue: "", localizedString: "" + ) + ) + + let key: String + var value: CookieValue + var editingText = "" +} diff --git a/EhPanda/View/Setting/AccountSettingView.swift b/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift similarity index 88% rename from EhPanda/View/Setting/AccountSettingView.swift rename to EhPanda/View/Setting/AccountSetting/AccountSettingView.swift index 0886ca43..6e796bd2 100644 --- a/EhPanda/View/Setting/AccountSettingView.swift +++ b/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift @@ -9,15 +9,15 @@ import SwiftUI import ComposableArchitecture struct AccountSettingView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf @Binding private var galleryHost: GalleryHost @Binding private var showsNewDawnGreeting: Bool private let bypassesSNIFiltering: Bool private let blurRadius: Double init( - store: Store, + store: StoreOf, galleryHost: Binding, showsNewDawnGreeting: Binding, bypassesSNIFiltering: Bool, blurRadius: Double ) { @@ -63,9 +63,9 @@ struct AccountSettingView: View { .progressHUD( config: viewStore.hudConfig, unwrapping: viewStore.binding(\.$route), - case: /AccountSettingState.Route.hud + case: /AccountSettingReducer.Route.hud ) - .sheet(unwrapping: viewStore.binding(\.$route), case: /AccountSettingState.Route.webView) { route in + .sheet(unwrapping: viewStore.binding(\.$route), case: /AccountSettingReducer.Route.webView) { route in WebView(url: route.wrappedValue) .autoBlur(radius: blurRadius) } @@ -78,15 +78,15 @@ struct AccountSettingView: View { // MARK: NavigationLinks private extension AccountSettingView { @ViewBuilder var navigationLinks: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /AccountSettingState.Route.login) { _ in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /AccountSettingReducer.Route.login) { _ in LoginView( - store: store.scope(state: \.loginState, action: AccountSettingAction.login), + store: store.scope(state: \.loginState, action: AccountSettingReducer.Action.login), bypassesSNIFiltering: bypassesSNIFiltering, blurRadius: blurRadius ) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /AccountSettingState.Route.ehSetting) { _ in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /AccountSettingReducer.Route.ehSetting) { _ in EhSettingView( - store: store.scope(state: \.ehSettingState, action: AccountSettingAction.ehSetting), + store: store.scope(state: \.ehSettingState, action: AccountSettingReducer.Action.ehSetting), bypassesSNIFiltering: bypassesSNIFiltering, blurRadius: blurRadius ) } @@ -95,7 +95,7 @@ private extension AccountSettingView { // MARK: AccountSection private struct AccountSection: View { - @Binding private var route: AccountSettingState.Route? + @Binding private var route: AccountSettingReducer.Route? @Binding private var showsNewDawnGreeting: Bool private let bypassesSNIFiltering: Bool private let loginAction: () -> Void @@ -105,7 +105,7 @@ private struct AccountSection: View { private let manageTagsAction: () -> Void init( - route: Binding, + route: Binding, showsNewDawnGreeting: Binding, bypassesSNIFiltering: Bool, loginAction: @escaping () -> Void, logoutAction: @escaping () -> Void, logoutDialogAction: @escaping () -> Void, @@ -123,7 +123,7 @@ private struct AccountSection: View { } var body: some View { - if !CookiesUtil.didLogin { + if !CookieUtil.didLogin { Button(L10n.Localizable.AccountSettingView.Button.login, action: loginAction) } else { Button( @@ -132,7 +132,7 @@ private struct AccountSection: View { ) .confirmationDialog( message: L10n.Localizable.ConfirmationDialog.Title.logout, - unwrapping: $route, case: /AccountSettingState.Route.logout + unwrapping: $route, case: /AccountSettingReducer.Route.logout ) { Button( L10n.Localizable.ConfirmationDialog.Button.logout, @@ -224,13 +224,7 @@ struct AccountSettingView_Previews: PreviewProvider { AccountSettingView( store: .init( initialState: .init(), - reducer: accountSettingReducer, - environment: AccountSettingEnvironment( - hapticClient: .live, - cookiesClient: .live, - clipboardClient: .live, - uiApplicationClient: .live - ) + reducer: AccountSettingReducer() ), galleryHost: .constant(.ehentai), showsNewDawnGreeting: .constant(false), diff --git a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift new file mode 100644 index 00000000..6eaa6b3d --- /dev/null +++ b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingReducer.swift @@ -0,0 +1,38 @@ +// +// AppearanceSettingReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/03. +// + +import ComposableArchitecture + +struct AppearanceSettingReducer: ReducerProtocol { + enum Route { + case appIcon + } + + struct State: Equatable { + @BindingState var route: Route? + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case setNavigation(Route?) + } + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + } + } + } +} diff --git a/EhPanda/View/Setting/AppearanceSettingView.swift b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift similarity index 94% rename from EhPanda/View/Setting/AppearanceSettingView.swift rename to EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift index 880b73b7..82fdcb6b 100644 --- a/EhPanda/View/Setting/AppearanceSettingView.swift +++ b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift @@ -9,8 +9,8 @@ import SwiftUI import ComposableArchitecture struct AppearanceSettingView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf @Binding private var preferredColorScheme: PreferredColorScheme @Binding private var accentColor: Color @@ -21,7 +21,7 @@ struct AppearanceSettingView: View { @Binding private var displaysJapaneseTitle: Bool init( - store: Store, + store: StoreOf, preferredColorScheme: Binding, accentColor: Binding, appIconType: Binding, @@ -107,7 +107,7 @@ struct AppearanceSettingView: View { } private var navigationLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /AppearanceSettingState.Route.appIcon) { _ in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /AppearanceSettingReducer.Route.appIcon) { _ in AppIconView(appIconType: $appIconType) } } @@ -230,8 +230,7 @@ struct AppearanceSettingView_Previews: PreviewProvider { AppearanceSettingView( store: .init( initialState: .init(), - reducer: appearanceSettingReducer, - environment: AppearanceSettingEnvironment() + reducer: AppearanceSettingReducer() ), preferredColorScheme: .constant(.automatic), accentColor: .constant(.blue), diff --git a/EhPanda/View/Setting/Components/AboutView.swift b/EhPanda/View/Setting/Components/AboutView.swift new file mode 100644 index 00000000..b01c48b5 --- /dev/null +++ b/EhPanda/View/Setting/Components/AboutView.swift @@ -0,0 +1,263 @@ +// +// AboutView.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/01/18. +// + +import SwiftUI + +struct AboutView: View { + private var version: String { + [ + L10n.Localizable.AboutView.Title.version, + AppUtil.version, "(\(AppUtil.build))" + ] + .joined(separator: " ") + } + + var body: some View { + HStack { + VStack(alignment: .leading) { + Text(L10n.Constant.App.copyright) + Text(version) + } + .foregroundStyle(.gray).font(.caption2.bold()) + Spacer() + } + .padding(.horizontal) + Form { + Section { + ForEach(contacts) { contact in + LinkRow(urlString: contact.urlString, text: contact.text) + } + } + Section(L10n.Localizable.AboutView.Section.Title.specialThanks) { + ForEach(specialThanks) { specialThank in + LinkRow(urlString: specialThank.urlString, text: specialThank.text) + } + } + Section(L10n.Localizable.AboutView.Section.Title.codeLevelContributors) { + ForEach(codeLevelContributors) { codeLevelContributor in + LinkRow(urlString: codeLevelContributor.urlString, text: codeLevelContributor.text) + } + } + Section(L10n.Localizable.AboutView.Section.Title.translationContributors) { + ForEach(translationContributors) { translationContributor in + LinkRow(urlString: translationContributor.urlString, text: translationContributor.text) + } + } + Section(L10n.Localizable.AboutView.Section.Title.acknowledgements) { + ForEach(acknowledgements) { acknowledgement in + LinkRow(urlString: acknowledgement.urlString, text: acknowledgement.text) + } + } + } + .navigationTitle(L10n.Localizable.AboutView.Title.ehPanda) + } + + // MARK: Contacts + private let contacts: [Info] = {[ + .init( + urlString: L10n.Constant.App.Contact.Link.website, + text: L10n.Localizable.AboutView.Button.website + ), + .init( + urlString: L10n.Constant.App.Contact.Link.gitHub, + text: L10n.Constant.App.Contact.Text.gitHub + ), + .init( + urlString: L10n.Constant.App.Contact.Link.discord, + text: L10n.Constant.App.Contact.Text.discord + ), + .init( + urlString: L10n.Constant.App.Contact.Link.telegram, + text: L10n.Constant.App.Contact.Text.telegram + ), + .init( + urlString: L10n.Constant.App.Contact.Link.altStore, + text: L10n.Localizable.AboutView.Button.altStoreSource + ) + ]}() + + // MARK: Special thanks + private let specialThanks: [Info] = {[ + .init( + urlString: L10n.Constant.App.SpecialThanks.Link.taylorlannister, + text: L10n.Constant.App.SpecialThanks.Text.taylorlannister + ), + .init( + urlString: L10n.Constant.App.SpecialThanks.Link.luminescentYq, + text: L10n.Constant.App.SpecialThanks.Text.luminescentYq + ), + .init( + urlString: L10n.Constant.App.SpecialThanks.Link.caxerx, + text: L10n.Constant.App.SpecialThanks.Text.caxerx + ), + .init( + urlString: L10n.Constant.App.SpecialThanks.Link.honjow, + text: L10n.Constant.App.SpecialThanks.Text.honjow + ) + ]}() + + // MARK: Code level contributors + private let codeLevelContributors: [Info] = {[ + .init( + urlString: L10n.Constant.App.CodeLevelContributor.Link.tatsuz0u, + text: L10n.Constant.App.CodeLevelContributor.Text.tatsuz0u + ), + .init( + urlString: L10n.Constant.App.CodeLevelContributor.Link.chihchy, + text: L10n.Constant.App.CodeLevelContributor.Text.chihchy + ), + .init( + urlString: L10n.Constant.App.CodeLevelContributor.Link.xioxin, + text: L10n.Constant.App.CodeLevelContributor.Text.xioxin + ), + .init( + urlString: L10n.Constant.App.CodeLevelContributor.Link.ethanChinCN, + text: L10n.Constant.App.CodeLevelContributor.Text.ethanChinCN + ), + .init( + urlString: L10n.Constant.App.CodeLevelContributor.Link.lengYue, + text: L10n.Constant.App.CodeLevelContributor.Text.lengYue + ) + ]}() + + // MARK: Translation contributors + private let translationContributors: [Info] = {[ + .init( + urlString: L10n.Constant.App.TranslationContributor.Link.tatsuz0u, + text: L10n.Constant.App.TranslationContributor.Text.tatsuz0u + ), + .init( + urlString: L10n.Constant.App.TranslationContributor.Link.nebulosaCat, + text: L10n.Constant.App.TranslationContributor.Text.nebulosaCat + ), + .init( + urlString: L10n.Constant.App.TranslationContributor.Link.paulHaeussler, + text: L10n.Constant.App.TranslationContributor.Text.paulHaeussler + ), + .init( + urlString: L10n.Constant.App.TranslationContributor.Link.caxerx, + text: L10n.Constant.App.TranslationContributor.Text.caxerx + ), + .init( + urlString: L10n.Constant.App.TranslationContributor.Link.nyaanim, + text: L10n.Constant.App.TranslationContributor.Text.nyaanim + ) + ]}() + + // MARK: Acknowledgements + private let acknowledgements: [Info] = {[ + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.kanna, + text: L10n.Constant.App.Acknowledgement.Text.kanna + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.swiftGen, + text: L10n.Constant.App.Acknowledgement.Text.swiftGen + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.alertKit, + text: L10n.Constant.App.Acknowledgement.Text.alertKit + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.colorful, + text: L10n.Constant.App.Acknowledgement.Text.colorful + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.filePicker, + text: L10n.Constant.App.Acknowledgement.Text.filePicker + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.kingfisher, + text: L10n.Constant.App.Acknowledgement.Text.kingfisher + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.swiftUIPager, + text: L10n.Constant.App.Acknowledgement.Text.swiftUIPager + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.swiftyBeaver, + text: L10n.Constant.App.Acknowledgement.Text.swiftyBeaver + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.waterfallGrid, + text: L10n.Constant.App.Acknowledgement.Text.waterfallGrid + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.swiftyOpenCC, + text: L10n.Constant.App.Acknowledgement.Text.swiftyOpenCC + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.uiImageColors, + text: L10n.Constant.App.Acknowledgement.Text.uiImageColors + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.sfSafeSymbols, + text: L10n.Constant.App.Acknowledgement.Text.sfSafeSymbols + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.ttProgressHUD, + text: L10n.Constant.App.Acknowledgement.Text.ttProgressHUD + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.swiftUINavigation, + text: L10n.Constant.App.Acknowledgement.Text.swiftUINavigation + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.swiftCommonMark, + text: L10n.Constant.App.Acknowledgement.Text.swiftCommonMark + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.ehTagTranslationDatabase, + text: L10n.Constant.App.Acknowledgement.Text.ehTagTranslationDatabase + ), + .init( + urlString: L10n.Constant.App.Acknowledgement.Link.tca, + text: L10n.Constant.App.Acknowledgement.Text.tca + ) + ]}() +} + +// MARK: LinkRow +private struct LinkRow: View { + private let urlString: String + private let text: String + + init(urlString: String, text: String) { + self.urlString = urlString + self.text = text + } + + var body: some View { + ZStack { + let text = Text(text).fontWeight(.medium) + if let url = URL(string: urlString) { + Link(destination: url) { + text.withArrow() + } + } else { + text + } + } + .foregroundColor(.primary) + } +} + +// MARK: Definition +private struct Info: Identifiable { + var id: String { urlString } + + let urlString: String + let text: String +} + +struct EhPandaView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + AboutView() + } + } +} diff --git a/EhPanda/View/Setting/LaboratorySettingView.swift b/EhPanda/View/Setting/Components/LaboratorySettingView.swift similarity index 100% rename from EhPanda/View/Setting/LaboratorySettingView.swift rename to EhPanda/View/Setting/Components/LaboratorySettingView.swift diff --git a/EhPanda/View/Setting/ReadingSettingView.swift b/EhPanda/View/Setting/Components/ReadingSettingView.swift similarity index 100% rename from EhPanda/View/Setting/ReadingSettingView.swift rename to EhPanda/View/Setting/Components/ReadingSettingView.swift diff --git a/EhPanda/View/Setting/Support/WebView.swift b/EhPanda/View/Setting/Components/WebView.swift similarity index 100% rename from EhPanda/View/Setting/Support/WebView.swift rename to EhPanda/View/Setting/Components/WebView.swift diff --git a/EhPanda/View/Setting/DataFlow/AccountSettingStore.swift b/EhPanda/View/Setting/DataFlow/AccountSettingStore.swift deleted file mode 100644 index 90af4ac2..00000000 --- a/EhPanda/View/Setting/DataFlow/AccountSettingStore.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// AccountSettingStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/12/31. -// - -import TTProgressHUD -import ComposableArchitecture - -struct CookieValue: Equatable { - static let empty: Self = .init( - rawValue: .init(), localizedString: .init() - ) - - let rawValue: String - let localizedString: String - - var isInvalid: Bool { - !localizedString.isEmpty && !rawValue.isEmpty - } - var placeholder: String { - localizedString.isEmpty ? rawValue : localizedString - } -} - -struct CookiesState: Equatable { - static func empty(_ host: GalleryHost) -> Self { - .init( - host: host, - igneous: .empty, - memberID: .empty, - passHash: .empty - ) - } - var allCases: [CookieState] {[ - igneous, memberID, passHash - ]} - - let host: GalleryHost - var igneous: CookieState - var memberID: CookieState - var passHash: CookieState -} - -struct CookieState: Equatable { - static let empty: Self = .init( - key: "", value: .init( - rawValue: "", localizedString: "" - ) - ) - - let key: String - var value: CookieValue - var editingText = "" -} - -struct AccountSettingState: Equatable { - enum Route: Equatable { - case hud - case login - case logout - case ehSetting - case webView(URL) - } - - @BindableState var route: Route? - @BindableState var ehCookiesState: CookiesState = .empty(.ehentai) - @BindableState var exCookiesState: CookiesState = .empty(.exhentai) - var hudConfig: TTProgressHUDConfig = .copiedToClipboardSucceeded - - var loginState = LoginState() - var ehSettingState = EhSettingState() -} - -enum AccountSettingAction: BindableAction, Equatable { - case binding(BindingAction) - case setNavigation(AccountSettingState.Route?) - case onLogoutConfirmButtonTapped - case clearSubStates - - case loadCookies - case copyCookies(GalleryHost) - - case login(LoginAction) - case ehSetting(EhSettingAction) -} - -struct AccountSettingEnvironment { - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let clipboardClient: ClipboardClient - let uiApplicationClient: UIApplicationClient -} - -let accountSettingReducer = Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding(\.$ehCookiesState): - return environment.cookiesClient.setCookies(state: state.ehCookiesState).fireAndForget() - - case .binding(\.$exCookiesState): - return environment.cookiesClient.setCookies(state: state.exCookiesState).fireAndForget() - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .onLogoutConfirmButtonTapped: - return .init(value: .loadCookies) - - case .clearSubStates: - state.loginState = .init() - state.ehSettingState = .init() - return .merge( - .init(value: .login(.teardown)), - .init(value: .ehSetting(.teardown)) - ) - - case .loadCookies: - state.ehCookiesState = environment.cookiesClient.loadCookiesState(host: .ehentai) - state.exCookiesState = environment.cookiesClient.loadCookiesState(host: .exhentai) - return .none - - case .copyCookies(let host): - let cookiesDescription = environment.cookiesClient.getCookiesDescription(host: host) - return .merge( - .init(value: .setNavigation(.hud)), - environment.clipboardClient.saveText(cookiesDescription).fireAndForget(), - environment.hapticClient.generateNotificationFeedback(.success).fireAndForget() - ) - - case .login(.loginDone): - return environment.cookiesClient.didLogin ? .init(value: .setNavigation(nil)) : .none - - case .login: - return .none - - case .ehSetting: - return .none - } - } - .haptics( - unwrapping: \.route, - case: /AccountSettingState.Route.webView, - hapticClient: \.hapticClient - ) - .binding(), - loginReducer.pullback( - state: \.loginState, - action: /AccountSettingAction.login, - environment: { - .init( - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient - ) - } - ), - ehSettingReducer.pullback( - state: \.ehSettingState, - action: /AccountSettingAction.ehSetting, - environment: { - .init( - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ) -) diff --git a/EhPanda/View/Setting/DataFlow/AppearanceSettingStore.swift b/EhPanda/View/Setting/DataFlow/AppearanceSettingStore.swift deleted file mode 100644 index 68f47a50..00000000 --- a/EhPanda/View/Setting/DataFlow/AppearanceSettingStore.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// AppearanceSettingStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/03. -// - -import ComposableArchitecture - -struct AppearanceSettingState: Equatable { - enum Route { - case appIcon - } - - @BindableState var route: Route? -} - -enum AppearanceSettingAction: BindableAction { - case binding(BindingAction) - case setNavigation(AppearanceSettingState.Route?) -} - -struct AppearanceSettingEnvironment {} - -let appearanceSettingReducer = Reducer -{ state, action, _ in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return .none - } -} -.binding() diff --git a/EhPanda/View/Setting/DataFlow/EhSettingStore.swift b/EhPanda/View/Setting/DataFlow/EhSettingStore.swift deleted file mode 100644 index 0847a0cb..00000000 --- a/EhPanda/View/Setting/DataFlow/EhSettingStore.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// EhSettingStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/01. -// - -import ComposableArchitecture - -struct EhSettingState: Equatable { - enum Route: Equatable { - case webView(URL) - case deleteProfile - } - struct CancelID: Hashable { - let id = String(describing: EhSettingState.self) - } - - @BindableState var route: Route? - @BindableState var editingProfileName = "" - @BindableState var ehSetting: EhSetting? - @BindableState var ehProfile: EhProfile? - var loadingState: LoadingState = .idle - var submittingState: LoadingState = .idle - - mutating func setEhSetting(_ ehSetting: EhSetting) { - let ehProfile: EhProfile = ehSetting.ehProfiles - .filter(\.isSelected).first.forceUnwrapped - self.ehSetting = ehSetting - self.ehProfile = ehProfile - editingProfileName = ehProfile.name - } -} - -enum EhSettingAction: BindableAction, Equatable { - case binding(BindingAction) - case setNavigation(EhSettingState.Route?) - case setKeyboardHidden - case setDefaultProfile(Int) - - case teardown - case fetchEhSetting - case fetchEhSettingDone(Result) - case submitChanges - case submitChangesDone(Result) - case performAction(EhProfileAction?, String?, Int) - case performActionDone(Result) -} - -struct EhSettingEnvironment { - let hapticClient: HapticClient - let cookiesClient: CookiesClient - let uiApplicationClient: UIApplicationClient -} - -let ehSettingReducer = Reducer { state, action, environment in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return .none - - case .setKeyboardHidden: - return environment.uiApplicationClient.hideKeyboard().fireAndForget() - - case .setDefaultProfile(let profileSet): - return environment.cookiesClient.setOrEditCookie( - for: Defaults.URL.host, key: Defaults.Cookie.selectedProfile, value: String(profileSet) - ) - .fireAndForget() - - case .teardown: - return .cancel(id: EhSettingState.CancelID()) - - case .fetchEhSetting: - guard state.loadingState != .loading else { return .none } - state.loadingState = .loading - return EhSettingRequest().effect.map(EhSettingAction.fetchEhSettingDone) - .cancellable(id: EhSettingState.CancelID()) - - case .fetchEhSettingDone(let result): - state.loadingState = .idle - - switch result { - case .success(let ehSetting): - state.setEhSetting(ehSetting) - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .submitChanges: - guard state.submittingState != .loading, - let ehSetting = state.ehSetting - else { return .none } - - state.submittingState = .loading - return SubmitEhSettingChangesRequest(ehSetting: ehSetting) - .effect.map(EhSettingAction.submitChangesDone).cancellable(id: EhSettingState.CancelID()) - - case .submitChangesDone(let result): - state.submittingState = .idle - - switch result { - case .success(let ehSetting): - state.setEhSetting(ehSetting) - case .failure(let error): - state.submittingState = .failed(error) - } - return .none - - case .performAction(let action, let name, let set): - guard state.submittingState != .loading else { return .none } - state.submittingState = .loading - return EhProfileRequest(action: action, name: name, set: set) - .effect.map(EhSettingAction.performActionDone).cancellable(id: EhSettingState.CancelID()) - - case .performActionDone(let result): - state.submittingState = .idle - - switch result { - case .success(let ehSetting): - state.setEhSetting(ehSetting) - case .failure(let error): - state.submittingState = .failed(error) - } - return .none - } -} -.haptics( - unwrapping: \.route, - case: /EhSettingState.Route.webView, - hapticClient: \.hapticClient -) -.binding() diff --git a/EhPanda/View/Setting/DataFlow/GeneralSettingStore.swift b/EhPanda/View/Setting/DataFlow/GeneralSettingStore.swift deleted file mode 100644 index 25855c5a..00000000 --- a/EhPanda/View/Setting/DataFlow/GeneralSettingStore.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// GeneralSettingStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/03. -// - -import Kingfisher -import LocalAuthentication -import ComposableArchitecture - -struct GeneralSettingState: Equatable { - enum Route { - case logs - case clearCache - case removeCustomTranslations - } - - @BindableState var route: Route? - - var loadingState: LoadingState = .idle - var diskImageCacheSize = "0 KB" - var passcodeNotSet = false - - var logsState = LogsState() -} - -enum GeneralSettingAction: BindableAction, Equatable { - case binding(BindingAction) - case setNavigation(GeneralSettingState.Route?) - case clearSubStates - case onTranslationsFilePicked(URL) - case onRemoveCustomTranslations - - case clearWebImageCache - case checkPasscodeSetting - case navigateToSystemSetting - case calculateWebImageDiskCache - case calculateWebImageDiskCacheDone(UInt?) - - case logs(LogsAction) -} - -struct GeneralSettingEnvironment { - let fileClient: FileClient - let loggerClient: LoggerClient - let libraryClient: LibraryClient - let databaseClient: DatabaseClient - let uiApplicationClient: UIApplicationClient - let authorizationClient: AuthorizationClient -} - -let generalSettingReducer = Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return route == nil ? .init(value: .clearSubStates) : .none - - case .clearSubStates: - state.logsState = .init() - return .init(value: .logs(.teardown)) - - case .onTranslationsFilePicked: - return .none - - case .onRemoveCustomTranslations: - return .none - - case .clearWebImageCache: - return .merge( - environment.libraryClient.clearWebImageDiskCache().fireAndForget(), - environment.databaseClient.removeImageURLs().fireAndForget(), - .init(value: .calculateWebImageDiskCache) - ) - - case .checkPasscodeSetting: - state.passcodeNotSet = environment.authorizationClient.passcodeNotSet() - return .none - - case .navigateToSystemSetting: - return environment.uiApplicationClient.openSettings().fireAndForget() - - case .calculateWebImageDiskCache: - return environment.libraryClient.calculateWebImageDiskCacheSize() - .map(GeneralSettingAction.calculateWebImageDiskCacheDone) - - case .calculateWebImageDiskCacheDone(let bytes): - guard let bytes = bytes else { return .none } - let formatter = ByteCountFormatter() - formatter.allowedUnits = .useAll - state.diskImageCacheSize = formatter.string(fromByteCount: .init(bytes)) - return .none - - case .logs: - return .none - } - } - .binding(), - logsReducer.pullback( - state: \.logsState, - action: /GeneralSettingAction.logs, - environment: { - .init( - fileClient: $0.fileClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ) -) diff --git a/EhPanda/View/Setting/DataFlow/LoginStore.swift b/EhPanda/View/Setting/DataFlow/LoginStore.swift deleted file mode 100644 index b2bd062e..00000000 --- a/EhPanda/View/Setting/DataFlow/LoginStore.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// LoginStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/01. -// - -import SwiftUI -import ComposableArchitecture - -struct LoginState: Equatable { - enum Route: Equatable { - case webView(URL) - } - enum FocusedField { - case username - case password - } - struct CancelID: Hashable { - let id = String(describing: LoginState.self) - } - - @BindableState var route: Route? - @BindableState var focusedField: FocusedField? - @BindableState var username = "" - @BindableState var password = "" - var loginState: LoadingState = .idle - - var loginButtonDisabled: Bool { - username.isEmpty || password.isEmpty - } - var loginButtonColor: Color { - loginState == .loading ? .clear : loginButtonDisabled - ? .primary.opacity(0.25) : .primary.opacity(0.75) - } -} - -enum LoginAction: BindableAction, Equatable { - case binding(BindingAction) - case setNavigation(LoginState.Route?) - - case teardown - case login - case loginDone(Result) -} - -struct LoginEnvironment { - let hapticClient: HapticClient - let cookiesClient: CookiesClient -} - -let loginReducer = Reducer { state, action, environment in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return .none - - case .teardown: - return .cancel(id: LoginState.CancelID()) - - case .login: - guard !state.loginButtonDisabled || state.loginState == .loading else { return .none } - state.focusedField = nil - state.loginState = .loading - return .merge( - environment.hapticClient.generateFeedback(.soft).fireAndForget(), - LoginRequest(username: state.username, password: state.password) - .effect.map(LoginAction.loginDone).cancellable(id: LoginState.CancelID()) - ) - - case .loginDone(let result): - state.route = nil - var effects = [Effect]() - if environment.cookiesClient.didLogin { - state.loginState = .idle - effects.append(environment.hapticClient.generateNotificationFeedback(.success).fireAndForget()) - } else { - state.loginState = .failed(.unknown) - effects.append(environment.hapticClient.generateNotificationFeedback(.error).fireAndForget()) - } - if case .success(let response) = result, let response = response { - effects.append(environment.cookiesClient.setCredentials(response: response).fireAndForget()) - } - return .merge(effects) - } -} -.haptics( - unwrapping: \.route, - case: /LoginState.Route.webView, - hapticClient: \.hapticClient -) -.binding() diff --git a/EhPanda/View/Setting/DataFlow/LogsStore.swift b/EhPanda/View/Setting/DataFlow/LogsStore.swift deleted file mode 100644 index 6e25fa1c..00000000 --- a/EhPanda/View/Setting/DataFlow/LogsStore.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// LogsStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/03. -// - -import ComposableArchitecture - -struct LogsState: Equatable { - enum Route: Equatable { - case log(Log) - } - struct CancelID: Hashable { - let id = String(describing: LogsState.self) - } - - @BindableState var route: Route? - var loadingState: LoadingState = .idle - var logs = [Log]() -} - -enum LogsAction: BindableAction, Equatable { - case binding(BindingAction) - case setNavigation(LogsState.Route?) - case navigateToFileApp - - case teardown - case fetchLogs - case fetchLogsDone(Result<[Log], AppError>) - case deleteLog(String) - case deleteLogDone(Result) -} - -struct LogsEnvironment { - let fileClient: FileClient - let uiApplicationClient: UIApplicationClient -} - -let logsReducer = Reducer { state, action, environment in - switch action { - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return .none - - case .navigateToFileApp: - return environment.uiApplicationClient.openFileApp().fireAndForget() - - case .teardown: - return .cancel(id: LogsState.CancelID()) - - case .fetchLogs: - guard state.loadingState != .loading else { return .none } - state.loadingState = .loading - return environment.fileClient.fetchLogs().map(LogsAction.fetchLogsDone).cancellable(id: LogsState.CancelID()) - - case .fetchLogsDone(let result): - switch result { - case .success(let logs): - state.logs = logs - state.loadingState = .idle - case .failure(let error): - state.loadingState = .failed(error) - } - return .none - - case .deleteLog(let fileName): - return environment.fileClient.deleteLog(fileName).map(LogsAction.deleteLogDone) - - case .deleteLogDone(let result): - if case .success(let fileName) = result { - state.logs = state.logs.filter({ $0.fileName != fileName }) - } - return .none - } -} -.binding() diff --git a/EhPanda/View/Setting/DataFlow/SettingStore.swift b/EhPanda/View/Setting/DataFlow/SettingStore.swift deleted file mode 100644 index 49360e34..00000000 --- a/EhPanda/View/Setting/DataFlow/SettingStore.swift +++ /dev/null @@ -1,489 +0,0 @@ -// -// SettingStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/12/31. -// - -import ComposableArchitecture - -struct SettingState: Equatable { - enum Route: Int, Hashable, Identifiable, CaseIterable { - var id: Int { rawValue } - - case account - case general - case appearance - case reading - case laboratory - case ehpanda - } - - // AppEnvStorage - @BindableState var setting = Setting() - var tagTranslator = TagTranslator() - var user = User() - - @BindableState var route: Route? - var tagTranslatorLoadingState: LoadingState = .idle - - var accountSettingState = AccountSettingState() - var generalSettingState = GeneralSettingState() - var appearanceSettingState = AppearanceSettingState() - - mutating func setGreeting(_ greeting: Greeting) { - guard let currDate = greeting.updateTime else { return } - - if let prevGreeting = user.greeting, - let prevDate = prevGreeting.updateTime, - prevDate < currDate - { - user.greeting = greeting - } else if user.greeting == nil { - user.greeting = greeting - } - } - mutating func updateUser(_ user: User) { - if let displayName = user.displayName { - self.user.displayName = displayName - } - if let avatarURL = user.avatarURL { - self.user.avatarURL = avatarURL - } - if let galleryPoints = user.galleryPoints, - let credits = user.credits - { - self.user.galleryPoints = galleryPoints - self.user.credits = credits - } - } -} - -enum SettingAction: BindableAction { - case binding(BindingAction) - case setNavigation(SettingState.Route?) - case clearSubStates - - case syncAppIconType - case syncUserInterfaceStyle - case syncSetting - case syncTagTranslator - case syncUser - - case loadUserSettings - case onLoadUserSettings(AppEnv) - case loadUserSettingsDone - case createDefaultEhProfile - case fetchIgneous - case fetchIgneousDone(Result) - case fetchUserInfo - case fetchUserInfoDone(Result) - case fetchGreeting - case fetchGreetingDone(Result) - case fetchTagTranslator - case fetchTagTranslatorDone(Result) - case fetchEhProfileIndex - case fetchEhProfileIndexDone(Result<(Int?, Bool), AppError>) - case fetchFavoriteCategories - case fetchFavoriteCategoriesDone(Result<[Int: String], AppError>) - - case account(AccountSettingAction) - case general(GeneralSettingAction) - case appearance(AppearanceSettingAction) -} - -struct SettingEnvironment { - let dfClient: DFClient - let fileClient: FileClient - let deviceClient: DeviceClient - let loggerClient: LoggerClient - let hapticClient: HapticClient - let libraryClient: LibraryClient - let cookiesClient: CookiesClient - let databaseClient: DatabaseClient - let clipboardClient: ClipboardClient - let appDelegateClient: AppDelegateClient - let userDefaultsClient: UserDefaultsClient - let uiApplicationClient: UIApplicationClient - let authorizationClient: AuthorizationClient -} - -let settingReducer = Reducer.combine( - .init { state, action, environment in - switch action { - case .binding(\.$setting.galleryHost): - return .merge( - .init(value: .syncSetting), - environment.userDefaultsClient - .setValue(state.setting.galleryHost.rawValue, .galleryHost).fireAndForget() - ) - - case .binding(\.$setting.enablesTagsExtension): - var effects: [Effect] = [ - .init(value: .syncSetting) - ] - if state.setting.enablesTagsExtension { - effects.append(.init(value: .fetchTagTranslator)) - } - return .merge(effects) - - case .binding(\.$setting.preferredColorScheme): - return .merge( - .init(value: .syncSetting), - .init(value: .syncUserInterfaceStyle) - ) - - case .binding(\.$setting.appIconType): - return .merge( - .init(value: .syncSetting), - environment.uiApplicationClient.setAlternateIconName(state.setting.appIconType.filename) - .map { _ in SettingAction.syncAppIconType } - ) - - case .binding(\.$setting.autoLockPolicy): - if state.setting.autoLockPolicy != .never - && state.setting.backgroundBlurRadius == 0 - { - state.setting.backgroundBlurRadius = 10 - } - return .init(value: .syncSetting) - - case .binding(\.$setting.backgroundBlurRadius): - if state.setting.autoLockPolicy != .never - && state.setting.backgroundBlurRadius == 0 - { - state.setting.autoLockPolicy = .never - } - return .init(value: .syncSetting) - - case .binding(\.$setting.enablesLandscape): - var effects: [Effect] = [ - .init(value: .syncSetting) - ] - if !state.setting.enablesLandscape && !environment.deviceClient.isPad() { - effects.append(environment.appDelegateClient.setPortraitOrientationMask().fireAndForget()) - } - return .merge(effects) - - case .binding(\.$setting.maximumScaleFactor): - if state.setting.doubleTapScaleFactor > state.setting.maximumScaleFactor { - state.setting.doubleTapScaleFactor = state.setting.maximumScaleFactor - } - return .init(value: .syncSetting) - - case .binding(\.$setting.doubleTapScaleFactor): - if state.setting.maximumScaleFactor < state.setting.doubleTapScaleFactor { - state.setting.maximumScaleFactor = state.setting.doubleTapScaleFactor - } - return .init(value: .syncSetting) - - case .binding(\.$setting.bypassesSNIFiltering): - return .merge( - .init(value: .syncSetting), - environment.hapticClient.generateFeedback(.soft).fireAndForget(), - environment.dfClient.setActive(state.setting.bypassesSNIFiltering).fireAndForget() - ) - - case .binding(\.$setting): - return .init(value: .syncSetting) - - case .binding(\.$route): - return .none - - case .binding: - return .merge( - .init(value: .syncUser), - .init(value: .syncSetting), - .init(value: .syncTagTranslator) - ) - - case .setNavigation(let route): - state.route = route - return .none - - case .clearSubStates: - state.accountSettingState = .init() - state.generalSettingState = .init() - state.appearanceSettingState = .init() - return .none - - case .syncAppIconType: - if let iconName = environment.uiApplicationClient.alternateIconName() { - state.setting.appIconType = AppIconType.allCases.filter({ - iconName.contains($0.filename) - }).first ?? .default - } - return .none - - case .syncUserInterfaceStyle: - let style = state.setting.preferredColorScheme.userInterfaceStyle - return environment.uiApplicationClient.setUserInterfaceStyle(style) - .subscribe(on: DispatchQueue.main).fireAndForget() - - case .syncSetting: - return environment.databaseClient.updateSetting(state.setting).fireAndForget() - case .syncTagTranslator: - return environment.databaseClient.updateTagTranslator(state.tagTranslator).fireAndForget() - case .syncUser: - return environment.databaseClient.updateUser(state.user).fireAndForget() - - case .loadUserSettings: - return environment.databaseClient.fetchAppEnv().map(SettingAction.onLoadUserSettings) - - case .onLoadUserSettings(let appEnv): - state.setting = appEnv.setting - state.tagTranslator = appEnv.tagTranslator - state.user = appEnv.user - var effects: [Effect] = [ - .init(value: .syncAppIconType), - .init(value: .loadUserSettingsDone), - .init(value: .syncUserInterfaceStyle), - environment.dfClient.setActive(state.setting.bypassesSNIFiltering).fireAndForget() - ] - if let value: String = environment.userDefaultsClient.getValue(.galleryHost), - let galleryHost = GalleryHost(rawValue: value) - { - state.setting.galleryHost = galleryHost - } - if environment.cookiesClient.shouldFetchIgneous { - effects.append(.init(value: .fetchIgneous)) - } - if environment.cookiesClient.didLogin { - effects.append(contentsOf: [ - .init(value: .fetchUserInfo), - .init(value: .fetchGreeting), - .init(value: .fetchFavoriteCategories), - .init(value: .fetchEhProfileIndex) - ]) - } - if state.setting.enablesTagsExtension { - effects.append(.init(value: .fetchTagTranslator)) - } - return .merge(effects) - - case .loadUserSettingsDone: - return .none - - case .createDefaultEhProfile: - return EhProfileRequest(action: .create, name: "EhPanda").effect.fireAndForget() - - case .fetchIgneous: - guard environment.cookiesClient.didLogin else { return .none } - return IgneousRequest().effect.map(SettingAction.fetchIgneousDone) - - case .fetchIgneousDone(let result): - var effects = [Effect]() - if case .success(let response) = result { - effects.append(environment.cookiesClient.setCredentials(response: response).fireAndForget()) - } - effects.append(.init(value: .account(.loadCookies))) - return .merge(effects) - - case .fetchUserInfo: - guard environment.cookiesClient.didLogin else { return .none } - let uid = environment.cookiesClient - .getCookie(Defaults.URL.host, Defaults.Cookie.ipbMemberId).rawValue - if !uid.isEmpty { - return UserInfoRequest(uid: uid).effect.map(SettingAction.fetchUserInfoDone) - } - return .none - - case .fetchUserInfoDone(let result): - if case .success(let user) = result { - state.updateUser(user) - return .init(value: .syncUser) - } - return .none - - case .fetchGreeting: - func verifyDate(with updateTime: Date?) -> Bool { - guard let updateTime = updateTime else { return true } - - let currentTime = Date() - let formatter = DateFormatter() - formatter.locale = Locale.current - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = Defaults.DateFormat.greeting - - let currentTimeString = formatter.string(from: currentTime) - if let currentDay = formatter.date(from: currentTimeString) { - return currentTime > currentDay && updateTime < currentDay - } - - return false - } - - guard environment.cookiesClient.didLogin, - state.setting.showsNewDawnGreeting - else { return .none } - let requestEffect = GreetingRequest().effect - .map(SettingAction.fetchGreetingDone) - if let greeting = state.user.greeting { - if verifyDate(with: greeting.updateTime) { - return requestEffect - } - } else { - return requestEffect - } - return .none - - case .fetchGreetingDone(let result): - switch result { - case .success(let greeting): - state.setGreeting(greeting) - return .init(value: .syncUser) - case .failure(let error): - if case .parseFailed = error { - var greeting = Greeting() - greeting.updateTime = Date() - state.setGreeting(greeting) - return .init(value: .syncUser) - } - } - return .none - - case .fetchTagTranslator: - guard state.tagTranslatorLoadingState != .loading, - !state.tagTranslator.hasCustomTranslations, - let language = TranslatableLanguage.current - else { return .none } - state.tagTranslatorLoadingState = .loading - - var databaseEffect: Effect? - if state.tagTranslator.language != language { - state.tagTranslator = TagTranslator(language: language) - databaseEffect = .init(value: .syncTagTranslator) - } - let updatedDate = state.tagTranslator.updatedDate - let requestEffect = TagTranslatorRequest(language: language, updatedDate: updatedDate) - .effect.map(SettingAction.fetchTagTranslatorDone) - if let databaseEffect = databaseEffect { - return .merge(databaseEffect, requestEffect) - } else { - return requestEffect - } - - case .fetchTagTranslatorDone(let result): - state.tagTranslatorLoadingState = .idle - switch result { - case .success(let tagTranslator): - state.tagTranslator = tagTranslator - return .init(value: .syncTagTranslator) - case .failure(let error): - state.tagTranslatorLoadingState = .failed(error) - } - return .none - - case .fetchEhProfileIndex: - guard environment.cookiesClient.didLogin else { return .none } - return VerifyEhProfileRequest().effect.map(SettingAction.fetchEhProfileIndexDone) - - case .fetchEhProfileIndexDone(let result): - var effects = [Effect]() - - if case .success(let (profileValue, profileNotFound)) = result { - if let profileValue = profileValue { - let hostURL = Defaults.URL.host - let profileValueString = String(profileValue) - let selectedProfileKey = Defaults.Cookie.selectedProfile - - let cookieValue = environment.cookiesClient.getCookie(hostURL, selectedProfileKey) - if cookieValue.rawValue != profileValueString { - effects.append( - environment.cookiesClient.setOrEditCookie( - for: hostURL, key: selectedProfileKey, value: profileValueString - ) - .fireAndForget() - ) - } - } else if profileNotFound { - effects.append(.init(value: .createDefaultEhProfile)) - } else { - let message = "Found profile but failed in parsing value." - effects.append(environment.loggerClient.error(message, nil).fireAndForget()) - } - } - return effects.isEmpty ? .none : .merge(effects) - - case .fetchFavoriteCategories: - guard environment.cookiesClient.didLogin else { return .none } - return FavoriteCategoriesRequest().effect.map(SettingAction.fetchFavoriteCategoriesDone) - - case .fetchFavoriteCategoriesDone(let result): - if case .success(let categories) = result { - state.user.favoriteCategories = categories - } - return .none - - case .account(.login(.loginDone)): - return .merge( - environment.cookiesClient.removeYay().fireAndForget(), - environment.cookiesClient.fulfillAnotherHostField().fireAndForget(), - .init(value: .fetchIgneous), - .init(value: .fetchUserInfo), - .init(value: .fetchFavoriteCategories), - .init(value: .fetchEhProfileIndex) - ) - - case .account(.onLogoutConfirmButtonTapped): - state.user = User() - return .merge( - .init(value: .syncUser), - environment.cookiesClient.clearAll().fireAndForget(), - environment.databaseClient.removeImageURLs().fireAndForget(), - environment.libraryClient.clearWebImageDiskCache().fireAndForget() - ) - - case .account: - return .none - - case .general(.onTranslationsFilePicked(let url)): - return environment.fileClient.importTagTranslator(url).map(SettingAction.fetchTagTranslatorDone) - - case .general(.onRemoveCustomTranslations): - state.tagTranslator.hasCustomTranslations = false - state.tagTranslator.translations = .init() - return .init(value: .syncTagTranslator) - - case .general: - return .none - - case .appearance: - return .none - } - } - .binding(), - accountSettingReducer.pullback( - state: \.accountSettingState, - action: /SettingAction.account, - environment: { - .init( - hapticClient: $0.hapticClient, - cookiesClient: $0.cookiesClient, - clipboardClient: $0.clipboardClient, - uiApplicationClient: $0.uiApplicationClient - ) - } - ), - generalSettingReducer.pullback( - state: \.generalSettingState, - action: /SettingAction.general, - environment: { - .init( - fileClient: $0.fileClient, - loggerClient: $0.loggerClient, - libraryClient: $0.libraryClient, - databaseClient: $0.databaseClient, - uiApplicationClient: $0.uiApplicationClient, - authorizationClient: $0.authorizationClient - ) - } - ), - appearanceSettingReducer.pullback( - state: \.appearanceSettingState, - action: /SettingAction.appearance, - environment: { _ in - .init() - } - ) -) diff --git a/EhPanda/View/Setting/EhPandaView.swift b/EhPanda/View/Setting/EhPandaView.swift deleted file mode 100644 index 7205d2ff..00000000 --- a/EhPanda/View/Setting/EhPandaView.swift +++ /dev/null @@ -1,263 +0,0 @@ -// -// EhPandaView.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/01/18. -// - -import SwiftUI - -struct EhPandaView: View { - private var version: String { - [ - L10n.Localizable.EhPandaView.Title.version, - AppUtil.version, "(\(AppUtil.build))" - ] - .joined(separator: " ") - } - - var body: some View { - HStack { - VStack(alignment: .leading) { - Text(L10n.Constant.EhPanda.copyright) - Text(version) - } - .foregroundStyle(.gray).font(.caption2.bold()) - Spacer() - } - .padding(.horizontal) - Form { - Section { - ForEach(contacts) { contact in - LinkRow(urlString: contact.urlString, text: contact.text) - } - } - Section(L10n.Localizable.EhPandaView.Section.Title.specialThanks) { - ForEach(specialThanks) { specialThank in - LinkRow(urlString: specialThank.urlString, text: specialThank.text) - } - } - Section(L10n.Localizable.EhPandaView.Section.Title.codeLevelContributors) { - ForEach(codeLevelContributors) { codeLevelContributor in - LinkRow(urlString: codeLevelContributor.urlString, text: codeLevelContributor.text) - } - } - Section(L10n.Localizable.EhPandaView.Section.Title.translationContributors) { - ForEach(translationContributors) { translationContributor in - LinkRow(urlString: translationContributor.urlString, text: translationContributor.text) - } - } - Section(L10n.Localizable.EhPandaView.Section.Title.acknowledgements) { - ForEach(acknowledgements) { acknowledgement in - LinkRow(urlString: acknowledgement.urlString, text: acknowledgement.text) - } - } - } - .navigationTitle(L10n.Localizable.EhPandaView.Title.ehPanda) - } - - // MARK: Contacts - private let contacts: [Info] = {[ - .init( - urlString: L10n.Constant.EhPanda.Contacts.Link.website, - text: L10n.Localizable.EhPandaView.Button.website - ), - .init( - urlString: L10n.Constant.EhPanda.Contacts.Link.gitHub, - text: L10n.Constant.EhPanda.Contacts.Text.gitHub - ), - .init( - urlString: L10n.Constant.EhPanda.Contacts.Link.discord, - text: L10n.Constant.EhPanda.Contacts.Text.discord - ), - .init( - urlString: L10n.Constant.EhPanda.Contacts.Link.telegram, - text: L10n.Constant.EhPanda.Contacts.Text.telegram - ), - .init( - urlString: L10n.Constant.EhPanda.Contacts.Link.altStore, - text: L10n.Localizable.EhPandaView.Button.altStoreSource - ) - ]}() - - // MARK: Special thanks - private let specialThanks: [Info] = {[ - .init( - urlString: L10n.Constant.EhPanda.SpecialThanks.Link.taylorlannister, - text: L10n.Constant.EhPanda.SpecialThanks.Text.taylorlannister - ), - .init( - urlString: L10n.Constant.EhPanda.SpecialThanks.Link.luminescentYq, - text: L10n.Constant.EhPanda.SpecialThanks.Text.luminescentYq - ), - .init( - urlString: L10n.Constant.EhPanda.SpecialThanks.Link.caxerx, - text: L10n.Constant.EhPanda.SpecialThanks.Text.caxerx - ), - .init( - urlString: L10n.Constant.EhPanda.SpecialThanks.Link.honjow, - text: L10n.Constant.EhPanda.SpecialThanks.Text.honjow - ) - ]}() - - // MARK: Code level contributors - private let codeLevelContributors: [Info] = {[ - .init( - urlString: L10n.Constant.EhPanda.CodeLevelContributors.Link.tatsuz0u, - text: L10n.Constant.EhPanda.CodeLevelContributors.Text.tatsuz0u - ), - .init( - urlString: L10n.Constant.EhPanda.CodeLevelContributors.Link.chihchy, - text: L10n.Constant.EhPanda.CodeLevelContributors.Text.chihchy - ), - .init( - urlString: L10n.Constant.EhPanda.CodeLevelContributors.Link.xioxin, - text: L10n.Constant.EhPanda.CodeLevelContributors.Text.xioxin - ), - .init( - urlString: L10n.Constant.EhPanda.CodeLevelContributors.Link.ethanChinCN, - text: L10n.Constant.EhPanda.CodeLevelContributors.Text.ethanChinCN - ), - .init( - urlString: L10n.Constant.EhPanda.CodeLevelContributors.Link.lengYue, - text: L10n.Constant.EhPanda.CodeLevelContributors.Text.lengYue - ) - ]}() - - // MARK: Translation contributors - private let translationContributors: [Info] = {[ - .init( - urlString: L10n.Constant.EhPanda.TranslationContributors.Link.tatsuz0u, - text: L10n.Constant.EhPanda.TranslationContributors.Text.tatsuz0u - ), - .init( - urlString: L10n.Constant.EhPanda.TranslationContributors.Link.nebulosaCat, - text: L10n.Constant.EhPanda.TranslationContributors.Text.nebulosaCat - ), - .init( - urlString: L10n.Constant.EhPanda.TranslationContributors.Link.paulHaeussler, - text: L10n.Constant.EhPanda.TranslationContributors.Text.paulHaeussler - ), - .init( - urlString: L10n.Constant.EhPanda.TranslationContributors.Link.caxerx, - text: L10n.Constant.EhPanda.TranslationContributors.Text.caxerx - ), - .init( - urlString: L10n.Constant.EhPanda.TranslationContributors.Link.nyaanim, - text: L10n.Constant.EhPanda.TranslationContributors.Text.nyaanim - ) - ]}() - - // MARK: Acknowledgements - private let acknowledgements: [Info] = {[ - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.kanna, - text: L10n.Constant.EhPanda.Acknowledgements.Text.kanna - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.swiftGen, - text: L10n.Constant.EhPanda.Acknowledgements.Text.swiftGen - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.alertKit, - text: L10n.Constant.EhPanda.Acknowledgements.Text.alertKit - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.colorful, - text: L10n.Constant.EhPanda.Acknowledgements.Text.colorful - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.filePicker, - text: L10n.Constant.EhPanda.Acknowledgements.Text.filePicker - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.kingfisher, - text: L10n.Constant.EhPanda.Acknowledgements.Text.kingfisher - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.swiftUIPager, - text: L10n.Constant.EhPanda.Acknowledgements.Text.swiftUIPager - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.swiftyBeaver, - text: L10n.Constant.EhPanda.Acknowledgements.Text.swiftyBeaver - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.waterfallGrid, - text: L10n.Constant.EhPanda.Acknowledgements.Text.waterfallGrid - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.swiftyOpenCC, - text: L10n.Constant.EhPanda.Acknowledgements.Text.swiftyOpenCC - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.uiImageColors, - text: L10n.Constant.EhPanda.Acknowledgements.Text.uiImageColors - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.sfSafeSymbols, - text: L10n.Constant.EhPanda.Acknowledgements.Text.sfSafeSymbols - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.ttProgressHUD, - text: L10n.Constant.EhPanda.Acknowledgements.Text.ttProgressHUD - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.swiftUINavigation, - text: L10n.Constant.EhPanda.Acknowledgements.Text.swiftUINavigation - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.swiftCommonMark, - text: L10n.Constant.EhPanda.Acknowledgements.Text.swiftCommonMark - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.ehTagTranslationDatabase, - text: L10n.Constant.EhPanda.Acknowledgements.Text.ehTagTranslationDatabase - ), - .init( - urlString: L10n.Constant.EhPanda.Acknowledgements.Link.tca, - text: L10n.Constant.EhPanda.Acknowledgements.Text.tca - ) - ]}() -} - -// MARK: LinkRow -private struct LinkRow: View { - private let urlString: String - private let text: String - - init(urlString: String, text: String) { - self.urlString = urlString - self.text = text - } - - var body: some View { - ZStack { - let text = Text(text).fontWeight(.medium) - if let url = URL(string: urlString) { - Link(destination: url) { - text.withArrow() - } - } else { - text - } - } - .foregroundColor(.primary) - } -} - -// MARK: Definition -private struct Info: Identifiable { - var id: String { urlString } - - let urlString: String - let text: String -} - -struct EhPandaView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - EhPandaView() - } - } -} diff --git a/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift b/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift new file mode 100644 index 00000000..c2ece436 --- /dev/null +++ b/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift @@ -0,0 +1,142 @@ +// +// EhSettingReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/01. +// + +import Foundation +import ComposableArchitecture + +struct EhSettingReducer: ReducerProtocol { + enum Route: Equatable { + case webView(URL) + case deleteProfile + } + + private enum CancelID: CaseIterable { + case fetchEhSetting, submitChanges, performAction + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var editingProfileName = "" + @BindingState var ehSetting: EhSetting? + @BindingState var ehProfile: EhProfile? + var loadingState: LoadingState = .idle + var submittingState: LoadingState = .idle + + mutating func setEhSetting(_ ehSetting: EhSetting) { + let ehProfile: EhProfile = ehSetting.ehProfiles + .filter(\.isSelected).first.forceUnwrapped + self.ehSetting = ehSetting + self.ehProfile = ehProfile + editingProfileName = ehProfile.name + } + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case setNavigation(Route?) + case setKeyboardHidden + case setDefaultProfile(Int) + + case teardown + case fetchEhSetting + case fetchEhSettingDone(Result) + case submitChanges + case submitChangesDone(Result) + case performAction(EhProfileAction?, String?, Int) + case performActionDone(Result) + } + + @Dependency(\.uiApplicationClient) private var uiApplicationClient + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.cookieClient) private var cookieClient + + public var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .setKeyboardHidden: + return uiApplicationClient.hideKeyboard().fireAndForget() + + case .setDefaultProfile(let profileSet): + return cookieClient.setOrEditCookie( + for: Defaults.URL.host, key: Defaults.Cookie.selectedProfile, value: String(profileSet) + ) + .fireAndForget() + + case .teardown: + return .cancel(ids: CancelID.allCases) + + case .fetchEhSetting: + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + return EhSettingRequest().effect.map(Action.fetchEhSettingDone) + .cancellable(id: CancelID.fetchEhSetting) + + case .fetchEhSettingDone(let result): + state.loadingState = .idle + + switch result { + case .success(let ehSetting): + state.setEhSetting(ehSetting) + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .submitChanges: + guard state.submittingState != .loading, + let ehSetting = state.ehSetting + else { return .none } + + state.submittingState = .loading + return SubmitEhSettingChangesRequest(ehSetting: ehSetting) + .effect.map(Action.submitChangesDone).cancellable(id: CancelID.submitChanges) + + case .submitChangesDone(let result): + state.submittingState = .idle + + switch result { + case .success(let ehSetting): + state.setEhSetting(ehSetting) + case .failure(let error): + state.submittingState = .failed(error) + } + return .none + + case .performAction(let action, let name, let set): + guard state.submittingState != .loading else { return .none } + state.submittingState = .loading + return EhProfileRequest(action: action, name: name, set: set) + .effect.map(Action.performActionDone).cancellable(id: CancelID.performAction) + + case .performActionDone(let result): + state.submittingState = .idle + + switch result { + case .success(let ehSetting): + state.setEhSetting(ehSetting) + case .failure(let error): + state.submittingState = .failed(error) + } + return .none + } + } + .haptics( + unwrapping: \.route, + case: /Route.webView, + hapticsClient: hapticsClient + ) + } +} diff --git a/EhPanda/View/Setting/Support/EhSettingView.swift b/EhPanda/View/Setting/EhSetting/EhSettingView.swift similarity index 90% rename from EhPanda/View/Setting/Support/EhSettingView.swift rename to EhPanda/View/Setting/EhSetting/EhSettingView.swift index 7a17afce..102c79b7 100644 --- a/EhPanda/View/Setting/Support/EhSettingView.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView.swift @@ -9,12 +9,12 @@ import SwiftUI import ComposableArchitecture struct EhSettingView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let bypassesSNIFiltering: Bool private let blurRadius: Double - init(store: Store, bypassesSNIFiltering: Bool, blurRadius: Double) { + init(store: StoreOf, bypassesSNIFiltering: Bool, blurRadius: Double) { self.store = store viewStore = ViewStore(store) self.bypassesSNIFiltering = bypassesSNIFiltering @@ -50,7 +50,7 @@ struct EhSettingView: View { viewStore.send(.setDefaultProfile(profileSet)) } } - .sheet(unwrapping: viewStore.binding(\.$route), case: /EhSettingState.Route.webView) { route in + .sheet(unwrapping: viewStore.binding(\.$route), case: /EhSettingReducer.Route.webView) { route in WebView(url: route.wrappedValue) .autoBlur(radius: blurRadius) } @@ -84,11 +84,10 @@ struct EhSettingView: View { FrontPageSettingsSection(ehSetting: ehSetting) FavoritesSection(ehSetting: ehSetting) RatingsSection(ehSetting: ehSetting) - TagNamespacesSection(ehSetting: ehSetting) TagFilteringThresholdSection(ehSetting: ehSetting) + TagWatchingThresholdSection(ehSetting: ehSetting) } Group { - TagWatchingThresholdSection(ehSetting: ehSetting) FilteredRemovalCountSection(ehSetting: ehSetting) ExcludedLanguagesSection(ehSetting: ehSetting) ExcludedUploadersSection(ehSetting: ehSetting) @@ -98,9 +97,9 @@ struct EhSettingView: View { ViewportOverrideSection(ehSetting: ehSetting) GalleryCommentsSection(ehSetting: ehSetting) GalleryTagsSection(ehSetting: ehSetting) + GalleryPageNumberingSection(ehSetting: ehSetting) } Group { - GalleryPageNumberingSection(ehSetting: ehSetting) OriginalImagesSection(ehSetting: ehSetting) MultiplePageViewerSection(ehSetting: ehSetting) } @@ -139,7 +138,7 @@ struct EhSettingView: View { // MARK: EhProfileSection private struct EhProfileSection: View { - @Binding private var route: EhSettingState.Route? + @Binding private var route: EhSettingReducer.Route? @Binding private var ehSetting: EhSetting @Binding private var ehProfile: EhProfile @Binding private var editingProfileName: String @@ -150,7 +149,7 @@ private struct EhProfileSection: View { @FocusState private var isFocused init( - route: Binding, ehSetting: Binding, + route: Binding, ehSetting: Binding, ehProfile: Binding, editingProfileName: Binding, deleteAction: @escaping () -> Void, deleteDialogAction: @escaping () -> Void, performEhProfileAction: @escaping (EhProfileAction?, String?, Int) -> Void @@ -187,7 +186,7 @@ private struct EhProfileSection: View { .confirmationDialog( message: L10n.Localizable.ConfirmationDialog.Title.delete, unwrapping: $route, - case: /EhSettingState.Route.deleteProfile + case: /EhSettingReducer.Route.deleteProfile ) { Button( L10n.Localizable.ConfirmationDialog.Button.delete, @@ -386,6 +385,16 @@ private struct FrontPageSettingsSection: View { } .textCase(nil) + Section { + Toggle( + L10n.Localizable.EhSettingView.Section.Title.showSearchRangeIndicator, + isOn: $ehSetting.showSearchRangeIndicator + ) + } header: { + Text(L10n.Localizable.EhSettingView.Section.Title.showSearchRangeIndicator) + } + .textCase(nil) + Section(L10n.Localizable.EhSettingView.Description.galleryCategory) { CategoryView(bindings: categoryBindings) } @@ -472,79 +481,6 @@ private struct RatingsSection: View { } } -// MARK: TagNamespacesSection -private struct TagNamespacesSection: View { - @Binding private var ehSetting: EhSetting - - init(ehSetting: Binding) { - _ehSetting = ehSetting - } - - private var tuples: [(String, Binding)] { - TagNamespace.allCases.dropLast().enumerated().map { index, namespace in - (namespace.value, $ehSetting.excludedNamespaces[index]) - } - } - - var body: some View { - Section { - ExcludeView(tuples: tuples) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.tagsNamespaces) - .newlineBold() - .appending(L10n.Localizable.EhSettingView.Description.tagsNamespaces) - } - .textCase(nil) - } -} - -private struct ExcludeView: View { - private let tuples: [(String, Binding)] - - init(tuples: [(String, Binding)]) { - self.tuples = tuples - } - - private let gridItems = [ - GridItem( - .adaptive( - minimum: DeviceUtil.isPadWidth ? 100 : 80, - maximum: 100 - ) - ) - ] - - var body: some View { - LazyVGrid(columns: gridItems) { - ForEach(tuples, id: \.0) { text, isExcluded in - ZStack { - Text(text) - .bold() - .opacity(isExcluded.wrappedValue ? 0 : 1) - ZStack { - Text(text) - - let width = (CGFloat(text.count) * 8) + 8 - let line = Rectangle() - .frame(width: width, height: 1) - VStack(spacing: 2) { - line - line - } - } - .foregroundColor(.red) - .opacity(isExcluded.wrappedValue ? 1 : 0) - } - .onTapGesture { - HapticUtil.generateFeedback(style: .soft) - withAnimation { isExcluded.wrappedValue.toggle() } - } - } - } - .padding(.vertical) - } -} - // MARK: TagFilteringThresholdSection private struct TagFilteringThresholdSection: View { @Binding private var ehSetting: EhSetting @@ -600,16 +536,14 @@ private struct FilteredRemovalCountSection: View { } var body: some View { - if let showFilteredRemovalCountBinding = Binding($ehSetting.showFilteredRemovalCount) { - Section { - Toggle( - L10n.Localizable.EhSettingView.Title.showFilteredRemovalCount, - isOn: showFilteredRemovalCountBinding - ) - } header: { - Text(L10n.Localizable.EhSettingView.Section.Title.filteredRemovalCount).newlineBold() - + Text(L10n.Localizable.EhSettingView.Description.filteredRemovalCount) - } + Section { + Toggle( + L10n.Localizable.EhSettingView.Title.showFilteredRemovalCount, + isOn: $ehSetting.showFilteredRemovalCount + ) + } header: { + Text(L10n.Localizable.EhSettingView.Section.Title.filteredRemovalCount).newlineBold() + + Text(L10n.Localizable.EhSettingView.Description.filteredRemovalCount) } } } @@ -714,7 +648,7 @@ private struct ExcludeToggle: View { } .onTapGesture { withAnimation { isOn.toggle() } - HapticUtil.generateFeedback(style: .soft) + HapticsUtil.generateFeedback(style: .soft) } } } @@ -1087,12 +1021,7 @@ struct EhSettingView_Previews: PreviewProvider { EhSettingView( store: .init( initialState: .init(ehSetting: .empty, ehProfile: .empty, loadingState: .idle), - reducer: ehSettingReducer, - environment: EhSettingEnvironment( - hapticClient: .live, - cookiesClient: .live, - uiApplicationClient: .live - ) + reducer: EhSettingReducer() ), bypassesSNIFiltering: false, blurRadius: 0 diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift new file mode 100644 index 00000000..1fcb65d4 --- /dev/null +++ b/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift @@ -0,0 +1,107 @@ +// +// GeneralSettingReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/03. +// + +import Kingfisher +import LocalAuthentication +import ComposableArchitecture + +struct GeneralSettingReducer: ReducerProtocol { + enum Route { + case logs + case clearCache + case removeCustomTranslations + } + + struct State: Equatable { + @BindingState var route: Route? + + var loadingState: LoadingState = .idle + var diskImageCacheSize = "0 KB" + var passcodeNotSet = false + + var logsState = LogsReducer.State() + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case setNavigation(Route?) + case clearSubStates + case onTranslationsFilePicked(URL) + case onRemoveCustomTranslations + + case clearWebImageCache + case checkPasscodeSetting + case navigateToSystemSetting + case calculateWebImageDiskCache + case calculateWebImageDiskCacheDone(UInt?) + + case logs(LogsReducer.Action) + } + + @Dependency(\.authorizationClient) private var authorizationClient + @Dependency(\.uiApplicationClient) private var uiApplicationClient + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.libraryClient) private var libraryClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$route): + return state.route == nil ? .init(value: .clearSubStates) : .none + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return route == nil ? .init(value: .clearSubStates) : .none + + case .clearSubStates: + state.logsState = .init() + return .init(value: .logs(.teardown)) + + case .onTranslationsFilePicked: + return .none + + case .onRemoveCustomTranslations: + return .none + + case .clearWebImageCache: + return .merge( + libraryClient.clearWebImageDiskCache().fireAndForget(), + databaseClient.removeImageURLs().fireAndForget(), + .init(value: .calculateWebImageDiskCache) + ) + + case .checkPasscodeSetting: + state.passcodeNotSet = authorizationClient.passcodeNotSet() + return .none + + case .navigateToSystemSetting: + return uiApplicationClient.openSettings().fireAndForget() + + case .calculateWebImageDiskCache: + return libraryClient.calculateWebImageDiskCacheSize() + .map(Action.calculateWebImageDiskCacheDone) + + case .calculateWebImageDiskCacheDone(let bytes): + guard let bytes = bytes else { return .none } + let formatter = ByteCountFormatter() + formatter.allowedUnits = .useAll + state.diskImageCacheSize = formatter.string(fromByteCount: .init(bytes)) + return .none + + case .logs: + return .none + } + } + + Scope(state: \.logsState, action: /Action.logs, child: LogsReducer.init) + } +} diff --git a/EhPanda/View/Setting/GeneralSettingView.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift similarity index 91% rename from EhPanda/View/Setting/GeneralSettingView.swift rename to EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift index 80649cd4..f85c77db 100644 --- a/EhPanda/View/Setting/GeneralSettingView.swift +++ b/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift @@ -10,8 +10,8 @@ import FilePicker import ComposableArchitecture struct GeneralSettingView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let tagTranslatorLoadingState: LoadingState private let tagTranslatorEmpty: Bool private let tagTranslatorHasCustomTranslations: Bool @@ -25,7 +25,7 @@ struct GeneralSettingView: View { @Binding private var autoLockPolicy: AutoLockPolicy init( - store: Store, + store: StoreOf, tagTranslatorLoadingState: LoadingState, tagTranslatorEmpty: Bool, tagTranslatorHasCustomTranslations: Bool, enablesTagsExtension: Binding, translatesTags: Binding, showsTagsSearchSuggestion: Binding, @@ -107,7 +107,7 @@ struct GeneralSettingView: View { .confirmationDialog( message: L10n.Localizable.ConfirmationDialog.Title.removeCustomTranslations, unwrapping: viewStore.binding(\.$route), - case: /GeneralSettingState.Route.removeCustomTranslations + case: /GeneralSettingReducer.Route.removeCustomTranslations ) { Button(L10n.Localizable.ConfirmationDialog.Button.remove, role: .destructive) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { @@ -165,7 +165,7 @@ struct GeneralSettingView: View { .confirmationDialog( message: L10n.Localizable.ConfirmationDialog.Title.clear, unwrapping: viewStore.binding(\.$route), - case: /GeneralSettingState.Route.clearCache + case: /GeneralSettingReducer.Route.clearCache ) { Button(L10n.Localizable.ConfirmationDialog.Button.clear, role: .destructive) { viewStore.send(.clearWebImageCache) @@ -186,8 +186,8 @@ struct GeneralSettingView: View { } private var navigationLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /GeneralSettingState.Route.logs) { _ in - LogsView(store: store.scope(state: \.logsState, action: GeneralSettingAction.logs)) + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /GeneralSettingReducer.Route.logs) { _ in + LogsView(store: store.scope(state: \.logsState, action: GeneralSettingReducer.Action.logs)) } } } @@ -198,15 +198,7 @@ struct GeneralSettingView_Previews: PreviewProvider { GeneralSettingView( store: .init( initialState: .init(), - reducer: generalSettingReducer, - environment: GeneralSettingEnvironment( - fileClient: .live, - loggerClient: .live, - libraryClient: .live, - databaseClient: .live, - uiApplicationClient: .live, - authorizationClient: .live - ) + reducer: GeneralSettingReducer() ), tagTranslatorLoadingState: .idle, tagTranslatorEmpty: false, diff --git a/EhPanda/View/Setting/Login/LoginReducer.swift b/EhPanda/View/Setting/Login/LoginReducer.swift new file mode 100644 index 00000000..fe2c7803 --- /dev/null +++ b/EhPanda/View/Setting/Login/LoginReducer.swift @@ -0,0 +1,100 @@ +// +// LoginReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/01. +// + +import SwiftUI +import ComposableArchitecture + +struct LoginReducer: ReducerProtocol { + private enum CancelID: Hashable { + case login + } + + enum Route: Equatable { + case webView(URL) + } + + enum FocusedField { + case username + case password + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var focusedField: FocusedField? + @BindingState var username = "" + @BindingState var password = "" + var loginState: LoadingState = .idle + + var loginButtonDisabled: Bool { + username.isEmpty || password.isEmpty + } + var loginButtonColor: Color { + loginState == .loading ? .clear : loginButtonDisabled + ? .primary.opacity(0.25) : .primary.opacity(0.75) + } + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case setNavigation(Route?) + + case teardown + case login + case loginDone(Result) + } + + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.cookieClient) private var cookieClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .teardown: + return .cancel(id: CancelID.self) + + case .login: + guard !state.loginButtonDisabled || state.loginState == .loading else { return .none } + state.focusedField = nil + state.loginState = .loading + return .merge( + .fireAndForget({ hapticsClient.generateFeedback(.soft) }), + LoginRequest(username: state.username, password: state.password) + .effect.map(Action.loginDone).cancellable(id: CancelID.login) + ) + + case .loginDone(let result): + state.route = nil + var effects = [EffectTask]() + if cookieClient.didLogin { + state.loginState = .idle + effects.append(.fireAndForget({ hapticsClient.generateNotificationFeedback(.success) })) + } else { + state.loginState = .failed(.unknown) + effects.append(.fireAndForget({ hapticsClient.generateNotificationFeedback(.error) })) + } + if case .success(let response) = result, let response = response { + effects.append(cookieClient.setCredentials(response: response).fireAndForget()) + } + return .merge(effects) + } + } + .haptics( + unwrapping: \.route, + case: /Route.webView, + hapticsClient: hapticsClient + ) + } +} diff --git a/EhPanda/View/Setting/Support/LoginView.swift b/EhPanda/View/Setting/Login/LoginView.swift similarity index 87% rename from EhPanda/View/Setting/Support/LoginView.swift rename to EhPanda/View/Setting/Login/LoginView.swift index 59e80285..eb5bde54 100644 --- a/EhPanda/View/Setting/Support/LoginView.swift +++ b/EhPanda/View/Setting/Login/LoginView.swift @@ -9,14 +9,14 @@ import SwiftUI import ComposableArchitecture struct LoginView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let bypassesSNIFiltering: Bool private let blurRadius: Double - @FocusState private var focusedField: LoginState.FocusedField? + @FocusState private var focusedField: LoginReducer.FocusedField? - init(store: Store, bypassesSNIFiltering: Bool, blurRadius: Double) { + init(store: StoreOf, bypassesSNIFiltering: Bool, blurRadius: Double) { self.store = store viewStore = ViewStore(store) self.bypassesSNIFiltering = bypassesSNIFiltering @@ -56,7 +56,7 @@ struct LoginView: View { } } .synchronize(viewStore.binding(\.$focusedField), $focusedField) - .sheet(unwrapping: viewStore.binding(\.$route), case: /LoginState.Route.webView) { route in + .sheet(unwrapping: viewStore.binding(\.$route), case: /LoginReducer.Route.webView) { route in WebView(url: route.wrappedValue) { viewStore.send(.loginDone(.success(nil))) } @@ -92,7 +92,7 @@ struct LoginView: View { // MARK: LoginTextField private struct LoginTextField: View { @Environment(\.colorScheme) private var colorScheme - private let focusedField: FocusState.Binding + private let focusedField: FocusState.Binding @Binding private var text: String private let description: String private let isPassword: Bool @@ -102,7 +102,7 @@ private struct LoginTextField: View { } init( - focusedField: FocusState.Binding, + focusedField: FocusState.Binding, text: Binding, description: String, isPassword: Bool ) { self.focusedField = focusedField @@ -136,11 +136,7 @@ struct LoginView_Previews: PreviewProvider { LoginView( store: .init( initialState: .init(), - reducer: loginReducer, - environment: LoginEnvironment( - hapticClient: .live, - cookiesClient: .live - ) + reducer: LoginReducer() ), bypassesSNIFiltering: false, blurRadius: 0 diff --git a/EhPanda/View/Setting/Logs/LogsReducer.swift b/EhPanda/View/Setting/Logs/LogsReducer.swift new file mode 100644 index 00000000..2b9e72d1 --- /dev/null +++ b/EhPanda/View/Setting/Logs/LogsReducer.swift @@ -0,0 +1,84 @@ +// +// LogsReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/03. +// + +import ComposableArchitecture + +struct LogsReducer: ReducerProtocol { + enum Route: Equatable { + case log(Log) + } + + private enum CancelID { + case fetchLogs + } + + struct State: Equatable { + @BindingState var route: Route? + var loadingState: LoadingState = .idle + var logs = [Log]() + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case setNavigation(Route?) + case navigateToFileApp + + case teardown + case fetchLogs + case fetchLogsDone(Result<[Log], AppError>) + case deleteLog(String) + case deleteLogDone(Result) + } + + @Dependency(\.uiApplicationClient) private var uiApplicationClient + @Dependency(\.fileClient) private var fileClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .navigateToFileApp: + return uiApplicationClient.openFileApp().fireAndForget() + + case .teardown: + return .cancel(id: CancelID.fetchLogs) + + case .fetchLogs: + guard state.loadingState != .loading else { return .none } + state.loadingState = .loading + return fileClient.fetchLogs().map(Action.fetchLogsDone).cancellable(id: CancelID.fetchLogs) + + case .fetchLogsDone(let result): + switch result { + case .success(let logs): + state.logs = logs + state.loadingState = .idle + case .failure(let error): + state.loadingState = .failed(error) + } + return .none + + case .deleteLog(let fileName): + return fileClient.deleteLog(fileName).map(Action.deleteLogDone) + + case .deleteLogDone(let result): + if case .success(let fileName) = result { + state.logs = state.logs.filter({ $0.fileName != fileName }) + } + return .none + } + } + } +} diff --git a/EhPanda/View/Setting/Support/LogsView.swift b/EhPanda/View/Setting/Logs/LogsView.swift similarity index 89% rename from EhPanda/View/Setting/Support/LogsView.swift rename to EhPanda/View/Setting/Logs/LogsView.swift index 0ba62cf8..0d519b11 100644 --- a/EhPanda/View/Setting/Support/LogsView.swift +++ b/EhPanda/View/Setting/Logs/LogsView.swift @@ -9,10 +9,10 @@ import SwiftUI import ComposableArchitecture struct LogsView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf - init(store: Store) { + init(store: StoreOf) { self.store = store viewStore = ViewStore(store) } @@ -56,7 +56,7 @@ struct LogsView: View { } private var navigationLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /LogsState.Route.log) { route in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /LogsReducer.Route.log) { route in LogView(log: route.wrappedValue) } } @@ -160,10 +160,13 @@ struct Log: Identifiable, Comparable { } extension Log: CustomStringConvertible { var description: String { - let params = String(describing: [ - "fileName": fileName, - "contentsCount": contents.count - ]) + let params = String( + describing: [ + "fileName": fileName, + "contentsCount": contents.count + ] + as [String: Any] + ) return "Log(\(params))" } } @@ -174,11 +177,7 @@ struct LogsView_Previews: PreviewProvider { LogsView( store: .init( initialState: .init(), - reducer: logsReducer, - environment: .init( - fileClient: .live, - uiApplicationClient: .live - ) + reducer: LogsReducer() ) ) } diff --git a/EhPanda/View/Setting/SettingReducer.swift b/EhPanda/View/Setting/SettingReducer.swift new file mode 100644 index 00000000..1e56cabd --- /dev/null +++ b/EhPanda/View/Setting/SettingReducer.swift @@ -0,0 +1,462 @@ +// +// SettingReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/31. +// + +import Foundation +import ComposableArchitecture + +struct SettingReducer: ReducerProtocol { + enum Route: Int, Equatable, Hashable, Identifiable, CaseIterable { + var id: Int { rawValue } + + case account + case general + case appearance + case reading + case laboratory + case about + } + + struct State: Equatable { + // AppEnvStorage + @BindingState var setting = Setting() + var tagTranslator = TagTranslator() + var user = User() + + @BindingState var route: Route? + var tagTranslatorLoadingState: LoadingState = .idle + + var accountSettingState = AccountSettingReducer.State() + var generalSettingState = GeneralSettingReducer.State() + var appearanceSettingState = AppearanceSettingReducer.State() + + mutating func setGreeting(_ greeting: Greeting) { + guard let currDate = greeting.updateTime else { return } + + if let prevGreeting = user.greeting, + let prevDate = prevGreeting.updateTime, + prevDate < currDate + { + user.greeting = greeting + } else if user.greeting == nil { + user.greeting = greeting + } + } + + mutating func updateUser(_ user: User) { + if let displayName = user.displayName { + self.user.displayName = displayName + } + if let avatarURL = user.avatarURL { + self.user.avatarURL = avatarURL + } + if let galleryPoints = user.galleryPoints, + let credits = user.credits + { + self.user.galleryPoints = galleryPoints + self.user.credits = credits + } + } + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case setNavigation(Route?) + case clearSubStates + + case syncAppIconType + case syncUserInterfaceStyle + case syncSetting + case syncTagTranslator + case syncUser + + case loadUserSettings + case onLoadUserSettings(AppEnv) + case loadUserSettingsDone + case createDefaultEhProfile + case fetchIgneous + case fetchIgneousDone(Result) + case fetchUserInfo + case fetchUserInfoDone(Result) + case fetchGreeting + case fetchGreetingDone(Result) + case fetchTagTranslator + case fetchTagTranslatorDone(Result) + case fetchEhProfileIndex + case fetchEhProfileIndexDone(Result) + case fetchFavoriteCategories + case fetchFavoriteCategoriesDone(Result<[Int: String], AppError>) + + case account(AccountSettingReducer.Action) + case general(GeneralSettingReducer.Action) + case appearance(AppearanceSettingReducer.Action) + } + + @Dependency(\.uiApplicationClient) private var uiApplicationClient + @Dependency(\.userDefaultsClient) private var userDefaultsClient + @Dependency(\.appDelegateClient) private var appDelegateClient + @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.libraryClient) private var libraryClient + @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.loggerClient) private var loggerClient + @Dependency(\.cookieClient) private var cookieClient + @Dependency(\.deviceClient) private var deviceClient + @Dependency(\.fileClient) private var fileClient + @Dependency(\.dfClient) private var dfClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$setting.galleryHost): + return .merge( + .init(value: .syncSetting), + userDefaultsClient + .setValue(state.setting.galleryHost.rawValue, .galleryHost).fireAndForget() + ) + + case .binding(\.$setting.enablesTagsExtension): + var effects: [EffectTask] = [ + .init(value: .syncSetting) + ] + if state.setting.enablesTagsExtension { + effects.append(.init(value: .fetchTagTranslator)) + } + return .merge(effects) + + case .binding(\.$setting.preferredColorScheme): + return .merge( + .init(value: .syncSetting), + .init(value: .syncUserInterfaceStyle) + ) + + case .binding(\.$setting.appIconType): + return .merge( + .init(value: .syncSetting), + uiApplicationClient.setAlternateIconName(state.setting.appIconType.filename) + .map { _ in Action.syncAppIconType } + ) + + case .binding(\.$setting.autoLockPolicy): + if state.setting.autoLockPolicy != .never + && state.setting.backgroundBlurRadius == 0 + { + state.setting.backgroundBlurRadius = 10 + } + return .init(value: .syncSetting) + + case .binding(\.$setting.backgroundBlurRadius): + if state.setting.autoLockPolicy != .never + && state.setting.backgroundBlurRadius == 0 + { + state.setting.autoLockPolicy = .never + } + return .init(value: .syncSetting) + + case .binding(\.$setting.enablesLandscape): + var effects: [EffectTask] = [ + .init(value: .syncSetting) + ] + if !state.setting.enablesLandscape && !deviceClient.isPad() { + effects.append(appDelegateClient.setPortraitOrientationMask().fireAndForget()) + } + return .merge(effects) + + case .binding(\.$setting.maximumScaleFactor): + if state.setting.doubleTapScaleFactor > state.setting.maximumScaleFactor { + state.setting.doubleTapScaleFactor = state.setting.maximumScaleFactor + } + return .init(value: .syncSetting) + + case .binding(\.$setting.doubleTapScaleFactor): + if state.setting.maximumScaleFactor < state.setting.doubleTapScaleFactor { + state.setting.maximumScaleFactor = state.setting.doubleTapScaleFactor + } + return .init(value: .syncSetting) + + case .binding(\.$setting.bypassesSNIFiltering): + return .merge( + .init(value: .syncSetting), + .fireAndForget({ hapticsClient.generateFeedback(.soft) }), + dfClient.setActive(state.setting.bypassesSNIFiltering).fireAndForget() + ) + + case .binding(\.$setting): + return .init(value: .syncSetting) + + case .binding(\.$route): + return .none + + case .binding: + return .merge( + .init(value: .syncUser), + .init(value: .syncSetting), + .init(value: .syncTagTranslator) + ) + + case .setNavigation(let route): + state.route = route + return .none + + case .clearSubStates: + state.accountSettingState = .init() + state.generalSettingState = .init() + state.appearanceSettingState = .init() + return .none + + case .syncAppIconType: + if let iconName = uiApplicationClient.alternateIconName() { + state.setting.appIconType = AppIconType.allCases.filter({ + iconName.contains($0.filename) + }).first ?? .default + } + return .none + + case .syncUserInterfaceStyle: + let style = state.setting.preferredColorScheme.userInterfaceStyle + return uiApplicationClient.setUserInterfaceStyle(style) + .subscribe(on: DispatchQueue.main).fireAndForget() + + case .syncSetting: + return databaseClient.updateSetting(state.setting).fireAndForget() + case .syncTagTranslator: + return databaseClient.updateTagTranslator(state.tagTranslator).fireAndForget() + case .syncUser: + return databaseClient.updateUser(state.user).fireAndForget() + + case .loadUserSettings: + return databaseClient.fetchAppEnv().map(Action.onLoadUserSettings) + + case .onLoadUserSettings(let appEnv): + state.setting = appEnv.setting + state.tagTranslator = appEnv.tagTranslator + state.user = appEnv.user + var effects: [EffectTask] = [ + .init(value: .syncAppIconType), + .init(value: .loadUserSettingsDone), + .init(value: .syncUserInterfaceStyle), + dfClient.setActive(state.setting.bypassesSNIFiltering).fireAndForget() + ] + if let value: String = userDefaultsClient.getValue(.galleryHost), + let galleryHost = GalleryHost(rawValue: value) + { + state.setting.galleryHost = galleryHost + } + if cookieClient.shouldFetchIgneous { + effects.append(.init(value: .fetchIgneous)) + } + if cookieClient.didLogin { + effects.append(contentsOf: [ + .init(value: .fetchUserInfo), + .init(value: .fetchGreeting), + .init(value: .fetchFavoriteCategories), + .init(value: .fetchEhProfileIndex) + ]) + } + if state.setting.enablesTagsExtension { + effects.append(.init(value: .fetchTagTranslator)) + } + return .merge(effects) + + case .loadUserSettingsDone: + return .none + + case .createDefaultEhProfile: + return EhProfileRequest(action: .create, name: "EhPanda").effect.fireAndForget() + + case .fetchIgneous: + guard cookieClient.didLogin else { return .none } + return IgneousRequest().effect.map(Action.fetchIgneousDone) + + case .fetchIgneousDone(let result): + var effects = [EffectTask]() + if case .success(let response) = result { + effects.append(cookieClient.setCredentials(response: response).fireAndForget()) + } + effects.append(.init(value: .account(.loadCookies))) + return .merge(effects) + + case .fetchUserInfo: + guard cookieClient.didLogin else { return .none } + let uid = cookieClient + .getCookie(Defaults.URL.host, Defaults.Cookie.ipbMemberId).rawValue + if !uid.isEmpty { + return UserInfoRequest(uid: uid).effect.map(Action.fetchUserInfoDone) + } + return .none + + case .fetchUserInfoDone(let result): + if case .success(let user) = result { + state.updateUser(user) + return .init(value: .syncUser) + } + return .none + + case .fetchGreeting: + func verifyDate(with updateTime: Date?) -> Bool { + guard let updateTime = updateTime else { return true } + + let currentTime = Date() + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = Defaults.DateFormat.greeting + + let currentTimeString = formatter.string(from: currentTime) + if let currentDay = formatter.date(from: currentTimeString) { + return currentTime > currentDay && updateTime < currentDay + } + + return false + } + + guard cookieClient.didLogin, + state.setting.showsNewDawnGreeting + else { return .none } + let requestEffect = GreetingRequest().effect + .map(Action.fetchGreetingDone) + if let greeting = state.user.greeting { + if verifyDate(with: greeting.updateTime) { + return requestEffect + } + } else { + return requestEffect + } + return .none + + case .fetchGreetingDone(let result): + switch result { + case .success(let greeting): + state.setGreeting(greeting) + return .init(value: .syncUser) + case .failure(let error): + if case .parseFailed = error { + var greeting = Greeting() + greeting.updateTime = Date() + state.setGreeting(greeting) + return .init(value: .syncUser) + } + } + return .none + + case .fetchTagTranslator: + guard state.tagTranslatorLoadingState != .loading, + !state.tagTranslator.hasCustomTranslations, + let language = TranslatableLanguage.current + else { return .none } + state.tagTranslatorLoadingState = .loading + + var databaseEffect: EffectTask? + if state.tagTranslator.language != language { + state.tagTranslator = TagTranslator(language: language) + databaseEffect = .init(value: .syncTagTranslator) + } + let updatedDate = state.tagTranslator.updatedDate + let requestEffect = TagTranslatorRequest(language: language, updatedDate: updatedDate) + .effect.map(Action.fetchTagTranslatorDone) + if let databaseEffect = databaseEffect { + return .merge(databaseEffect, requestEffect) + } else { + return requestEffect + } + + case .fetchTagTranslatorDone(let result): + state.tagTranslatorLoadingState = .idle + switch result { + case .success(let tagTranslator): + state.tagTranslator = tagTranslator + return .init(value: .syncTagTranslator) + case .failure(let error): + state.tagTranslatorLoadingState = .failed(error) + } + return .none + + case .fetchEhProfileIndex: + guard cookieClient.didLogin else { return .none } + return VerifyEhProfileRequest().effect.map(Action.fetchEhProfileIndexDone) + + case .fetchEhProfileIndexDone(let result): + var effects = [EffectTask]() + + if case .success(let response) = result { + if let profileValue = response.profileValue { + let hostURL = Defaults.URL.host + let profileValueString = String(profileValue) + let selectedProfileKey = Defaults.Cookie.selectedProfile + + let cookieValue = cookieClient.getCookie(hostURL, selectedProfileKey) + if cookieValue.rawValue != profileValueString { + effects.append( + cookieClient.setOrEditCookie( + for: hostURL, key: selectedProfileKey, value: profileValueString + ) + .fireAndForget() + ) + } + } else if response.isProfileNotFound { + effects.append(.init(value: .createDefaultEhProfile)) + } else { + let message = "Found profile but failed in parsing value." + effects.append(loggerClient.error(message, nil).fireAndForget()) + } + } + return effects.isEmpty ? .none : .merge(effects) + + case .fetchFavoriteCategories: + guard cookieClient.didLogin else { return .none } + return FavoriteCategoriesRequest().effect.map(Action.fetchFavoriteCategoriesDone) + + case .fetchFavoriteCategoriesDone(let result): + if case .success(let categories) = result { + state.user.favoriteCategories = categories + } + return .none + + case .account(.login(.loginDone)): + return .merge( + cookieClient.removeYay().fireAndForget(), + cookieClient.syncExCookies().fireAndForget(), + cookieClient.fulfillAnotherHostField().fireAndForget(), + .init(value: .fetchIgneous), + .init(value: .fetchUserInfo), + .init(value: .fetchFavoriteCategories), + .init(value: .fetchEhProfileIndex) + ) + + case .account(.onLogoutConfirmButtonTapped): + state.user = User() + return .merge( + .init(value: .syncUser), + cookieClient.clearAll().fireAndForget(), + databaseClient.removeImageURLs().fireAndForget(), + libraryClient.clearWebImageDiskCache().fireAndForget() + ) + + case .account: + return .none + + case .general(.onTranslationsFilePicked(let url)): + return fileClient.importTagTranslator(url).map(Action.fetchTagTranslatorDone) + + case .general(.onRemoveCustomTranslations): + state.tagTranslator.hasCustomTranslations = false + state.tagTranslator.translations = .init() + return .init(value: .syncTagTranslator) + + case .general: + return .none + + case .appearance: + return .none + } + } + + Scope(state: \.accountSettingState, action: /Action.account, child: AccountSettingReducer.init) + Scope(state: \.generalSettingState, action: /Action.general, child: GeneralSettingReducer.init) + Scope(state: \.appearanceSettingState, action: /Action.appearance, child: AppearanceSettingReducer.init) + } +} diff --git a/EhPanda/View/Setting/SettingView.swift b/EhPanda/View/Setting/SettingView.swift index afeda7bf..f4d267b2 100644 --- a/EhPanda/View/Setting/SettingView.swift +++ b/EhPanda/View/Setting/SettingView.swift @@ -10,11 +10,11 @@ import SFSafeSymbols import ComposableArchitecture struct SettingView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf private let blurRadius: Double - init(store: Store, blurRadius: Double) { + init(store: StoreOf, blurRadius: Double) { self.store = store viewStore = ViewStore(store) self.blurRadius = blurRadius @@ -25,7 +25,7 @@ struct SettingView: View { NavigationView { ScrollView { VStack(spacing: 0) { - ForEach(SettingState.Route.allCases) { route in + ForEach(SettingReducer.Route.allCases) { route in SettingRow(rowType: route) { viewStore.send(.setNavigation($0)) } @@ -42,9 +42,9 @@ struct SettingView: View { // MARK: NavigationLinks private extension SettingView { @ViewBuilder var navigationLinks: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingState.Route.account) { _ in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingReducer.Route.account) { _ in AccountSettingView( - store: store.scope(state: \.accountSettingState, action: SettingAction.account), + store: store.scope(state: \.accountSettingState, action: SettingReducer.Action.account), galleryHost: viewStore.binding(\.$setting.galleryHost), showsNewDawnGreeting: viewStore.binding(\.$setting.showsNewDawnGreeting), bypassesSNIFiltering: viewStore.setting.bypassesSNIFiltering, @@ -52,9 +52,9 @@ private extension SettingView { ) .tint(viewStore.setting.accentColor) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingState.Route.general) { _ in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingReducer.Route.general) { _ in GeneralSettingView( - store: store.scope(state: \.generalSettingState, action: SettingAction.general), + store: store.scope(state: \.generalSettingState, action: SettingReducer.Action.general), tagTranslatorLoadingState: viewStore.tagTranslatorLoadingState, tagTranslatorEmpty: viewStore.tagTranslator.translations.isEmpty, tagTranslatorHasCustomTranslations: viewStore.tagTranslator.hasCustomTranslations, @@ -69,9 +69,9 @@ private extension SettingView { ) .tint(viewStore.setting.accentColor) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingState.Route.appearance) { _ in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingReducer.Route.appearance) { _ in AppearanceSettingView( - store: store.scope(state: \.appearanceSettingState, action: SettingAction.appearance), + store: store.scope(state: \.appearanceSettingState, action: SettingReducer.Action.appearance), preferredColorScheme: viewStore.binding(\.$setting.preferredColorScheme), accentColor: viewStore.binding(\.$setting.accentColor), appIconType: viewStore.binding(\.$setting.appIconType), @@ -82,7 +82,7 @@ private extension SettingView { ) .tint(viewStore.setting.accentColor) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingState.Route.reading) { _ in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingReducer.Route.reading) { _ in ReadingSettingView( readingDirection: viewStore.binding(\.$setting.readingDirection), prefetchLimit: viewStore.binding(\.$setting.prefetchLimit), @@ -93,14 +93,14 @@ private extension SettingView { ) .tint(viewStore.setting.accentColor) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingState.Route.laboratory) { _ in + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingReducer.Route.laboratory) { _ in LaboratorySettingView( bypassesSNIFiltering: viewStore.binding(\.$setting.bypassesSNIFiltering) ) .tint(viewStore.setting.accentColor) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingState.Route.ehpanda) { _ in - EhPandaView().tint(viewStore.setting.accentColor) + NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingReducer.Route.about) { _ in + AboutView().tint(viewStore.setting.accentColor) } } } @@ -110,8 +110,8 @@ private struct SettingRow: View { @Environment(\.colorScheme) private var colorScheme @State private var isPressing = false - private let rowType: SettingState.Route - private let tapAction: (SettingState.Route) -> Void + private let rowType: SettingReducer.Route + private let tapAction: (SettingReducer.Route) -> Void private var color: Color { colorScheme == .light ? Color(.darkGray) : Color(.lightGray) @@ -120,7 +120,7 @@ private struct SettingRow: View { isPressing ? color.opacity(0.1) : .clear } - init(rowType: SettingState.Route, tapAction: @escaping (SettingState.Route) -> Void) { + init(rowType: SettingReducer.Route, tapAction: @escaping (SettingReducer.Route) -> Void) { self.rowType = rowType self.tapAction = tapAction } @@ -145,7 +145,7 @@ private struct SettingRow: View { } // MARK: Definition -extension SettingState.Route { +extension SettingReducer.Route { var value: String { switch self { case .account: @@ -158,8 +158,8 @@ extension SettingState.Route { return L10n.Localizable.Enum.SettingStateRoute.Value.reading case .laboratory: return L10n.Localizable.Enum.SettingStateRoute.Value.laboratory - case .ehpanda: - return L10n.Localizable.Enum.SettingStateRoute.Value.ehPanda + case .about: + return L10n.Localizable.Enum.SettingStateRoute.Value.about } } var symbol: SFSymbol { @@ -174,7 +174,7 @@ extension SettingState.Route { return .newspaperFill case .laboratory: return .testtube2 - case .ehpanda: + case .about: return .pCircleFill } } @@ -185,22 +185,7 @@ struct SettingView_Previews: PreviewProvider { SettingView( store: .init( initialState: .init(), - reducer: settingReducer, - environment: SettingEnvironment( - dfClient: .live, - fileClient: .live, - deviceClient: .live, - loggerClient: .live, - hapticClient: .live, - libraryClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live, - userDefaultsClient: .live, - uiApplicationClient: .live, - authorizationClient: .live - ) + reducer: SettingReducer() ), blurRadius: 0 ) diff --git a/EhPanda/View/Support/Components/CategoryView.swift b/EhPanda/View/Support/Components/CategoryView.swift index 33a77ea4..1c4df40a 100644 --- a/EhPanda/View/Support/Components/CategoryView.swift +++ b/EhPanda/View/Support/Components/CategoryView.swift @@ -84,7 +84,7 @@ private struct CategoryCell: View { } .onTapGesture { isFiltered.toggle() - HapticUtil.generateFeedback(style: .soft) + HapticsUtil.generateFeedback(style: .soft) } .cornerRadius(5) } diff --git a/EhPanda/View/Support/Components/SubSection.swift b/EhPanda/View/Support/Components/SubSection.swift index 9b63deaa..674046f9 100644 --- a/EhPanda/View/Support/Components/SubSection.swift +++ b/EhPanda/View/Support/Components/SubSection.swift @@ -37,7 +37,7 @@ struct SubSection: View { HStack { Button { reloadAction?() - HapticUtil.generateFeedback(style: .soft) + HapticsUtil.generateFeedback(style: .soft) } label: { HStack(spacing: 10) { Text(title).font(.title3.bold()) diff --git a/EhPanda/View/Support/Components/TagSuggestionView.swift b/EhPanda/View/Support/Components/TagSuggestionView.swift index e2db0b59..df103b58 100644 --- a/EhPanda/View/Support/Components/TagSuggestionView.swift +++ b/EhPanda/View/Support/Components/TagSuggestionView.swift @@ -25,63 +25,22 @@ struct TagSuggestionView: View { var body: some View { if isEnabled { - DoubleHorizontalSuggestionsStack( - suggestions: translationHandler.suggestions, showsImages: showsImages, - action: { translationHandler.autoComplete(suggestion: $0, keyword: &keyword) } - ) - .onChange(of: keyword) { _ in translationHandler.analyze(text: &keyword, translations: translations) } - } - } -} - -// MARK: DoubleHorizontalSuggestionsStack -private struct DoubleHorizontalSuggestionsStack: View { - private let suggestions: [TagSuggestion] - private let showsImages: Bool - private let action: (TagSuggestion) -> Void - - init(suggestions: [TagSuggestion], showsImages: Bool, action: @escaping (TagSuggestion) -> Void) { - self.suggestions = suggestions - self.showsImages = showsImages - self.action = action - } - - var singleSuggestions: [TagSuggestion] { - .init(suggestions.prefix(min(suggestions.count, 10))) - } - var doubleSuggestions: [(TagSuggestion, TagSuggestion?)] { - suggestions.enumerated().compactMap { (index, suggestion) in - if index < 20, index % 2 == 0 { - if index + 1 < suggestions.count { - return (suggestion, suggestions[index + 1]) - } else { - return (suggestion, nil) - } - } else { - return nil + if DeviceUtil.isPhone { + Text(L10n.Localizable.Searchable.Title.matchesCount(translationHandler.suggestions.count)) + .foregroundColor(.secondary) + .font(.subheadline) } - } - } - var body: some View { - if !DeviceUtil.isPad { - ForEach(singleSuggestions) { suggestion in - SuggestionCell(suggestion: suggestion, showsImages: showsImages) { - action(suggestion) - } + let suggestions = translationHandler.suggestions + ForEach(suggestions.prefix(min(suggestions.count, 10))) { suggestion in + SuggestionCell( + suggestion: suggestion, + showsImages: showsImages, + action: { translationHandler.autoComplete(suggestion: suggestion, keyword: &keyword) } + ) } - } else { - ForEach(doubleSuggestions, id: \.0) { leadingSuggestion, trailingSuggestion in - HStack(spacing: 30) { - SuggestionCell(suggestion: leadingSuggestion, showsImages: showsImages) { - action(leadingSuggestion) - } - if let trailingSuggestion = trailingSuggestion { - SuggestionCell(suggestion: trailingSuggestion, showsImages: showsImages) { - action(trailingSuggestion) - } - } - } + .onChange(of: keyword) { _ in + translationHandler.analyze(text: &keyword, translations: translations) } } } @@ -105,24 +64,42 @@ private struct SuggestionCell: View { } var body: some View { - HStack(spacing: 20) { - Image(systemSymbol: .magnifyingglass) - VStack(alignment: .leading) { - HStack(spacing: 2) { - Text(displayValue.localizedKey) - if let imageURL = suggestion.tag.valueImageURL, showsImages { - Image(systemSymbol: .photo).opacity(0) - .overlay(KFImage(imageURL).resizable().scaledToFit()) + if DeviceUtil.isPhone { + HStack(spacing: 20) { + Image(systemSymbol: .magnifyingglass) + + VStack(alignment: .leading) { + HStack(spacing: 2) { + Text(displayValue.localizedKey) + + if let imageURL = suggestion.tag.valueImageURL, showsImages { + Image(systemSymbol: .photo) + .opacity(0) + .overlay( + KFImage(imageURL) + .resizable() + .scaledToFit() + ) + } } + .font(.callout) + .lineLimit(1) + + Text(suggestion.displayKey.localizedKey) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) } - .font(.callout).lineLimit(1) - Text(suggestion.displayKey.localizedKey).font(.caption).foregroundColor(.secondary).lineLimit(1) + .allowsHitTesting(false) + + Spacer() } - .allowsHitTesting(false) - Spacer() + .contentShape(Rectangle()) + .onTapGesture(perform: action) + } else { + (Text(displayValue.localizedKey) + Text("\n") + Text(suggestion.displayKey.localizedKey)) + .searchCompletion(suggestion.tag.searchKeyword) } - .contentShape(Rectangle()) - .onTapGesture(perform: action) } } diff --git a/EhPanda/View/Support/Components/ToolbarItems.swift b/EhPanda/View/Support/Components/ToolbarItems.swift index 636880df..727b4966 100644 --- a/EhPanda/View/Support/Components/ToolbarItems.swift +++ b/EhPanda/View/Support/Components/ToolbarItems.swift @@ -136,7 +136,7 @@ struct FavoritesIndexMenu: View { } } } label: { - Image(systemSymbol: .dialMin) + Image(systemSymbol: .dialLow) .symbolRenderingMode(.hierarchical) } } @@ -164,7 +164,7 @@ struct ToplistsTypeMenu: View { } } } label: { - Image(systemSymbol: .dialMin) + Image(systemSymbol: .dialLow) .symbolRenderingMode(.hierarchical) } } diff --git a/EhPanda/View/Support/FiltersReducer.swift b/EhPanda/View/Support/FiltersReducer.swift new file mode 100644 index 00000000..83121f01 --- /dev/null +++ b/EhPanda/View/Support/FiltersReducer.swift @@ -0,0 +1,114 @@ +// +// FiltersReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 4/01/09. +// + +import ComposableArchitecture + +struct FiltersReducer: ReducerProtocol { + enum Route { + case resetFilters + } + + enum FocusedBound { + case lower + case upper + } + + struct State: Equatable { + @BindingState var route: Route? + @BindingState var filterRange: FilterRange = .search + @BindingState var focusedBound: FocusedBound? + + @BindingState var searchFilter = Filter() + @BindingState var globalFilter = Filter() + @BindingState var watchedFilter = Filter() + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case setNavigation(Route?) + case onTextFieldSubmitted + + case syncFilter(FilterRange) + case resetFilters + case fetchFilters + case fetchFiltersDone(AppEnv) + } + + @Dependency(\.databaseClient) private var databaseClient + + var body: some ReducerProtocol { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.$searchFilter): + state.searchFilter.fixInvalidData() + return .init(value: .syncFilter(.search)) + + case .binding(\.$globalFilter): + state.globalFilter.fixInvalidData() + return .init(value: .syncFilter(.global)) + + case .binding(\.$watchedFilter): + state.watchedFilter.fixInvalidData() + return .init(value: .syncFilter(.watched)) + + case .binding: + return .none + + case .setNavigation(let route): + state.route = route + return .none + + case .onTextFieldSubmitted: + switch state.focusedBound { + case .lower: + state.focusedBound = .upper + case .upper: + state.focusedBound = nil + default: + break + } + return .none + + case .syncFilter(let range): + let filter: Filter + switch range { + case .search: + filter = state.searchFilter + case .global: + filter = state.globalFilter + case .watched: + filter = state.watchedFilter + } + return databaseClient.updateFilter(filter, range: range).fireAndForget() + + case .resetFilters: + switch state.filterRange { + case .search: + state.searchFilter = .init() + return .init(value: .syncFilter(.search)) + case .global: + state.globalFilter = .init() + return .init(value: .syncFilter(.global)) + case .watched: + state.watchedFilter = .init() + return .init(value: .syncFilter(.watched)) + } + + case .fetchFilters: + return databaseClient.fetchAppEnv().map(Action.fetchFiltersDone) + + case .fetchFiltersDone(let appEnv): + state.searchFilter = appEnv.searchFilter + state.globalFilter = appEnv.globalFilter + state.watchedFilter = appEnv.watchedFilter + return .none + } + } + } +} diff --git a/EhPanda/View/Support/FiltersStore.swift b/EhPanda/View/Support/FiltersStore.swift deleted file mode 100644 index d47ee16f..00000000 --- a/EhPanda/View/Support/FiltersStore.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// FiltersStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 4/01/09. -// - -import ComposableArchitecture - -struct FiltersState: Equatable { - enum Route { - case resetFilters - } - enum FocusedBound { - case lower - case upper - } - - @BindableState var route: Route? - @BindableState var filterRange: FilterRange = .search - @BindableState var focusedBound: FocusedBound? - - @BindableState var searchFilter = Filter() - @BindableState var globalFilter = Filter() - @BindableState var watchedFilter = Filter() -} - -enum FiltersAction: BindableAction { - case binding(BindingAction) - case setNavigation(FiltersState.Route?) - case onTextFieldSubmitted - - case syncFilter(FilterRange) - case resetFilters - case fetchFilters - case fetchFiltersDone(AppEnv) -} - -struct FiltersEnvironment { - let databaseClient: DatabaseClient -} - -let filtersReducer = Reducer { state, action, environment in - switch action { - case .binding(\.$searchFilter): - state.searchFilter.fixInvalidData() - return .init(value: .syncFilter(.search)) - - case .binding(\.$globalFilter): - state.globalFilter.fixInvalidData() - return .init(value: .syncFilter(.global)) - - case .binding(\.$watchedFilter): - state.watchedFilter.fixInvalidData() - return .init(value: .syncFilter(.watched)) - - case .binding: - return .none - - case .setNavigation(let route): - state.route = route - return .none - - case .onTextFieldSubmitted: - switch state.focusedBound { - case .lower: - state.focusedBound = .upper - case .upper: - state.focusedBound = nil - default: - break - } - return .none - - case .syncFilter(let range): - let filter: Filter - switch range { - case .search: - filter = state.searchFilter - case .global: - filter = state.globalFilter - case .watched: - filter = state.watchedFilter - } - return environment.databaseClient.updateFilter(filter, range: range).fireAndForget() - - case .resetFilters: - switch state.filterRange { - case .search: - state.searchFilter = .init() - return .init(value: .syncFilter(.search)) - case .global: - state.globalFilter = .init() - return .init(value: .syncFilter(.global)) - case .watched: - state.watchedFilter = .init() - return .init(value: .syncFilter(.watched)) - } - - case .fetchFilters: - return environment.databaseClient.fetchAppEnv().map(FiltersAction.fetchFiltersDone) - - case .fetchFiltersDone(let appEnv): - state.searchFilter = appEnv.searchFilter - state.globalFilter = appEnv.globalFilter - state.watchedFilter = appEnv.watchedFilter - return .none - } -} -.binding() diff --git a/EhPanda/View/Support/FiltersView.swift b/EhPanda/View/Support/FiltersView.swift index d98af131..5ac42d18 100644 --- a/EhPanda/View/Support/FiltersView.swift +++ b/EhPanda/View/Support/FiltersView.swift @@ -9,12 +9,12 @@ import SwiftUI import ComposableArchitecture struct FiltersView: View { - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf - @FocusState private var focusedBound: FiltersState.FocusedBound? + @FocusState private var focusedBound: FiltersReducer.FocusedBound? - init(store: Store) { + init(store: StoreOf) { self.store = store viewStore = ViewStore(store) } @@ -54,7 +54,7 @@ struct FiltersView: View { // MARK: BasicSection private struct BasicSection: View { - @Binding private var route: FiltersState.Route? + @Binding private var route: FiltersReducer.Route? @Binding private var filter: Filter @Binding private var filterRange: FilterRange private let resetFiltersAction: () -> Void @@ -65,7 +65,7 @@ private struct BasicSection: View { ] } init( - route: Binding, filter: Binding, filterRange: Binding, + route: Binding, filter: Binding, filterRange: Binding, resetFiltersAction: @escaping () -> Void, resetFiltersDialogAction: @escaping () -> Void ) { _route = route @@ -89,7 +89,7 @@ private struct BasicSection: View { } .confirmationDialog( message: L10n.Localizable.ConfirmationDialog.Title.reset, - unwrapping: $route, case: /FiltersState.Route.resetFilters + unwrapping: $route, case: /FiltersReducer.Route.resetFilters ) { Button( L10n.Localizable.ConfirmationDialog.Button.reset, @@ -104,12 +104,12 @@ private struct BasicSection: View { // MARK: AdvancedSection private struct AdvancedSection: View { @Binding private var filter: Filter - private let focusedBound: FocusState.Binding + private let focusedBound: FocusState.Binding private let submitAction: () -> Void init( filter: Binding, - focusedBound: FocusState.Binding, + focusedBound: FocusState.Binding, submitAction: @escaping () -> Void ) { _filter = filter @@ -178,13 +178,13 @@ private struct MinimumRatingSetter: View { private struct PagesRangeSetter: View { @Binding private var lowerBound: String @Binding private var upperBound: String - private let focusedBound: FocusState.Binding + private let focusedBound: FocusState.Binding private let submitAction: () -> Void init( lowerBound: Binding, upperBound: Binding, - focusedBound: FocusState.Binding, + focusedBound: FocusState.Binding, submitAction: @escaping () -> Void ) { _lowerBound = lowerBound @@ -242,10 +242,7 @@ struct FiltersView_Previews: PreviewProvider { FiltersView( store: .init( initialState: .init(), - reducer: filtersReducer, - environment: FiltersEnvironment( - databaseClient: .live - ) + reducer: FiltersReducer() ) ) } diff --git a/EhPanda/View/TabBar/TabBarReducer.swift b/EhPanda/View/TabBar/TabBarReducer.swift new file mode 100644 index 00000000..a60089ee --- /dev/null +++ b/EhPanda/View/TabBar/TabBarReducer.swift @@ -0,0 +1,32 @@ +// +// TabBarReducer.swift +// EhPanda +// +// Created by 荒木辰造 on R 3/12/29. +// + +import ComposableArchitecture + +struct TabBarReducer: ReducerProtocol { + struct State: Equatable { + var tabBarItemType: TabBarItemType = .home + } + + enum Action: Equatable { + case setTabBarItemType(TabBarItemType) + } + + @Dependency(\.deviceClient) private var deviceClient + + var body: some ReducerProtocol { + Reduce { state, action in + switch action { + case .setTabBarItemType(let type): + if !deviceClient.isPad() || type != .setting { + state.tabBarItemType = type + } + return .none + } + } + } +} diff --git a/EhPanda/View/TabBar/TabBarStore.swift b/EhPanda/View/TabBar/TabBarStore.swift deleted file mode 100644 index 3da6db7c..00000000 --- a/EhPanda/View/TabBar/TabBarStore.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// TabBarStore.swift -// EhPanda -// -// Created by 荒木辰造 on R 3/12/29. -// - -import ComposableArchitecture - -struct TabBarState: Equatable { - var tabBarItemType: TabBarItemType = .home -} - -enum TabBarAction { - case setTabBarItemType(TabBarItemType) -} - -struct TabBarEnvironment { - let deviceClient: DeviceClient -} - -let tabBarReducer = Reducer { state, action, environment in - switch action { - case .setTabBarItemType(let type): - if !environment.deviceClient.isPad() || type != .setting { - state.tabBarItemType = type - } - return .none - } -} diff --git a/EhPanda/View/TabBar/TabBarView.swift b/EhPanda/View/TabBar/TabBarView.swift index 82cfff25..5c4a83a3 100644 --- a/EhPanda/View/TabBar/TabBarView.swift +++ b/EhPanda/View/TabBar/TabBarView.swift @@ -11,10 +11,10 @@ import ComposableArchitecture struct TabBarView: View { @Environment(\.scenePhase) private var scenePhase - private let store: Store - @ObservedObject private var viewStore: ViewStore + private let store: StoreOf + @ObservedObject private var viewStore: ViewStoreOf - init(store: Store) { + init(store: StoreOf) { self.store = store viewStore = ViewStore(store) } @@ -32,7 +32,7 @@ struct TabBarView: View { switch type { case .home: HomeView( - store: store.scope(state: \.homeState, action: AppAction.home), + store: store.scope(state: \.homeState, action: AppReducer.Action.home), user: viewStore.settingState.user, setting: viewStore.binding(\.settingState.$setting), blurRadius: viewStore.appLockState.blurRadius, @@ -40,7 +40,7 @@ struct TabBarView: View { ) case .favorites: FavoritesView( - store: store.scope(state: \.favoritesState, action: AppAction.favorites), + store: store.scope(state: \.favoritesState, action: AppReducer.Action.favorites), user: viewStore.settingState.user, setting: viewStore.binding(\.settingState.$setting), blurRadius: viewStore.appLockState.blurRadius, @@ -48,7 +48,7 @@ struct TabBarView: View { ) case .search: SearchRootView( - store: store.scope(state: \.searchRootState, action: AppAction.searchRoot), + store: store.scope(state: \.searchRootState, action: AppReducer.Action.searchRoot), user: viewStore.settingState.user, setting: viewStore.binding(\.settingState.$setting), blurRadius: viewStore.appLockState.blurRadius, @@ -56,7 +56,7 @@ struct TabBarView: View { ) case .setting: SettingView( - store: store.scope(state: \.settingState, action: AppAction.setting), + store: store.scope(state: \.settingState, action: AppReducer.Action.setting), blurRadius: viewStore.appLockState.blurRadius ) } @@ -73,22 +73,25 @@ struct TabBarView: View { } .font(.system(size: 80)).opacity(viewStore.appLockState.isAppLocked ? 1 : 0) } - .sheet(unwrapping: viewStore.binding(\.appRouteState.$route), case: /AppRouteState.Route.newDawn) { route in + .sheet(unwrapping: viewStore.binding(\.appRouteState.$route), case: /AppRouteReducer.Route.newDawn) { route in NewDawnView(greeting: route.wrappedValue) .autoBlur(radius: viewStore.appLockState.blurRadius) } - .sheet(unwrapping: viewStore.binding(\.appRouteState.$route), case: /AppRouteState.Route.setting) { _ in + .sheet(unwrapping: viewStore.binding(\.appRouteState.$route), case: /AppRouteReducer.Route.setting) { _ in SettingView( - store: store.scope(state: \.settingState, action: AppAction.setting), + store: store.scope(state: \.settingState, action: AppReducer.Action.setting), blurRadius: viewStore.appLockState.blurRadius ) .accentColor(viewStore.settingState.setting.accentColor) .autoBlur(radius: viewStore.appLockState.blurRadius) } - .sheet(unwrapping: viewStore.binding(\.appRouteState.$route), case: /AppRouteState.Route.detail) { route in + .sheet(unwrapping: viewStore.binding(\.appRouteState.$route), case: /AppRouteReducer.Route.detail) { route in NavigationView { DetailView( - store: store.scope(state: \.appRouteState.detailState, action: { AppAction.appRoute(.detail($0)) }), + store: store.scope( + state: \.appRouteState.detailState, + action: { AppReducer.Action.appRoute(.detail($0)) } + ), gid: route.wrappedValue, user: viewStore.settingState.user, setting: viewStore.binding(\.settingState.$setting), blurRadius: viewStore.appLockState.blurRadius, @@ -103,7 +106,7 @@ struct TabBarView: View { .progressHUD( config: viewStore.appRouteState.hudConfig, unwrapping: viewStore.binding(\.appRouteState.$route), - case: /AppRouteState.Route.hud + case: /AppRouteReducer.Route.hud ) .onChange(of: scenePhase) { viewStore.send(.onScenePhaseChange($0)) } .onOpenURL { viewStore.send(.appRoute(.handleDeepLink($0))) } @@ -155,24 +158,7 @@ struct TabBarView_Previews: PreviewProvider { TabBarView( store: .init( initialState: .init(), - reducer: appReducer, - environment: AppEnvironment( - dfClient: .live, - urlClient: .live, - fileClient: .live, - imageClient: .live, - deviceClient: .live, - loggerClient: .live, - hapticClient: .live, - libraryClient: .live, - cookiesClient: .live, - databaseClient: .live, - clipboardClient: .live, - appDelegateClient: .live, - userDefaultsClient: .live, - uiApplicationClient: .live, - authorizationClient: .live - ) + reducer: AppReducer() ) ) } diff --git a/EhPandaTests/Resources/Parser/Other/EhSetting.html b/EhPandaTests/Resources/Parser/Other/EhSetting.html index eb38b168..3cd3dee4 100644 --- a/EhPandaTests/Resources/Parser/Other/EhSetting.html +++ b/EhPandaTests/Resources/Parser/Other/EhSetting.html @@ -1,14 +1,14 @@ - + E-Hentai Galleries - The Free Hentai Doujinshi, Manga and Image Gallery System - + - - - + + + @@ -16,8 +16,8 @@ @@ -40,401 +40,411 @@
-
-
Selected Profile:
-
-
- - - -
-
-
-
+
+
Selected Profile:
+
+
+ + + +
+
+
+
-
+
-
+ +

Image Load Settings

-

Do you wish to load images through the Hentai@Home Network, if available?

-
-

-

-

-

-
+

Do you wish to load images through the Hentai@Home Network, if available?

+
+

+

+

+

+
-
-

You appear to be browsing the site from Japan or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below.

-
- Browsing Country: -
+

You appear to be browsing the site from Japan or use a VPN or proxy in this country, which means the site will try to load images from H@H clients in this general geographic region. If this is incorrect, or if you want to use a different region for any reason (like if you are using a split tunneling VPN), you can select a different country below.

+
+ Browsing Country: +

Image Size Settings

-
-

Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000.

-
-
-
-
-
-
-
-
+

Normally, images are resampled to 1280 pixels of horizontal resolution for online viewing. You can alternatively select one of the following resample resolutions. To avoid murdering the staging servers, resolutions above 1280x are temporarily restricted to donators, people with any hath perk, and people with a UID below 3,000,000.

+
+
+
+
+
+
+
+
-
-

While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)

-
- - - - - - - -
Horizontal: pixels
Vertical: pixels
-
+

While the site will automatically scale down images to fit your screen width, you can also manually restrict the maximum display size of an image. Like the automatic scaling, this does not resample the image, as the resizing is done browser-side. (0 = no limit)

+
+ + + + + + + +
Horizontal: pixels
Vertical: pixels
+
+

Gallery Name Display

-

Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?

-
-

-

-
+

Many galleries have both an English/Romanized title and a title in Japanese script. Which gallery name would you like as default?

+
+

+

+

Archiver Settings

-

The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here.

-
-

-

-

-

-

-

-
+

The default behavior for the Archiver is to confirm the cost and selection for original or resampled archive, then present a link that can be clicked or copied elsewhere. You can change this behavior here.

+
+

+

+

+

+

+

+
-

Front Page Settings

+

Front Page / Search Settings

+ +
+

Which display mode would you like to use on the front and search pages?

+
+

+

+

+

+

+
+
-

Which display mode would you like to use on the front and search pages?

-
-

-

-

-

-

-
+

Which display style would you like for the search range indicator?

+
+

+

+
-

What categories would you like to show by default on the front page and in searches?

-
Doujinshi
Manga
Artist CG
Game CG
Western
Non-H
Image Set
Cosplay
Asian Porn
Misc
-
+

What categories would you like to show by default on the front page and in searches?

+
Doujinshi
Manga
Artist CG
Game CG
Western
Non-H
Image Set
Cosplay
Asian Porn
Misc
+

Favorites

-

Here you can choose and rename your favorite categories.

-
+

Here you can choose and rename your favorite categories.

+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+

You can also select your default sort order for galleries on your favorites page. Note that favorites added prior to the March 2016 revamp did not store a timestamp, and will use the gallery posted time regardless of this setting.

-
-

-

-
+
+

+

+

Ratings

-

By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below.

- - - - - -
Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works.
+

By default, galleries that you have rated will appear with red stars for ratings of 2 stars and below, green for ratings between 2.5 and 4 stars, and blue for ratings of 4.5 or 5 stars. You can customize this by entering your desired color combination below.

+ + + + + +
Each letter represents one star. The default RRGGB means R(ed) for the first and second star, G(reen) for the third and fourth, and B(lue) for the fifth. You can also use (Y)ellow for the normal stars. Any five-letter R/G/B/Y combo works.
- -

Tag Namespaces

+ +

Tag Watching Threshold

-

If you want to exclude certain namespaces from a default tag search, you can check those below. Note that this does not prevent galleries with tags in these namespaces from appearing, it just makes it so that when searching tags, it will forego those namespaces.

-
-
+ + + + + +
Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999.
+

Tag Filtering Threshold

- - - - - -
You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999.
+ + + + + +
You can soft filter tags by adding them to My Tags with a negative weight. If a gallery has tags that add up to weight below this value, it is filtered from view. This threshold can be set between 0 and -9999.
- -

Tag Watching Threshold

+ +

Show Filtered Removal Count

- - - - - -
Recently uploaded galleries will be included on the watched screen if it has at least one watched tag with positive weight, and the sum of weights on its watched tags add up to this value or higher. This threshold can be set between 0 and 9999.
+

Show the "Your custom filters removed XX galleries from this page" readout?

+
+

+

+

Excluded Languages

-

If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below.

-

Note that matching galleries will never appear regardless of your search query.

-
- +

If you wish to hide galleries in certain languages from the gallery list and searches, select them from the list below.

+

Note that matching galleries will never appear regardless of your search query.

+
+
- - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + -
OriginalTranslatedRewriteAllOriginalTranslatedRewriteAll
Japanese
Japanese
English
English
Chinese
Chinese
Dutch
Dutch
French
French
German
German
Hungarian
Hungarian
Italian
Italian
Korean
Korean
Polish
Polish
Portuguese
Portuguese
Russian
Russian
Spanish
Spanish
Thai
Thai
Vietnamese
Vietnamese
N/A
N/A
Other
Other
-
+ +
@@ -442,12 +452,12 @@

Excluded Languages

Excluded Uploaders

-

If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line.

-

Note that galleries from these uploaders will never appear regardless of your search query.

-
- -
-

You are currently using 0 of 1000 exclusion slots. +

If you wish to hide galleries from certain uploaders from the gallery list and searches, add them below. Put one username per line.

+

Note that galleries from these uploaders will never appear regardless of your search query.

+
+ +
+

You are currently using 0 of 1000 exclusion slots.

@@ -455,149 +465,148 @@

Excluded Uploaders

Search Result Count

-

How many results would you like per page for the index/search page and torrent search pages? (Hath Perk: Paging Enlargement Required)

-
-

-

-

-

-
+

How many results would you like per page for the index/search page and torrent search pages? (Hath Perk: Paging Enlargement Required)

+
+

+

+

+

Thumbnail Settings

-

How would you like the mouse-over thumbnails on the front page to load when using List Mode?

-
-

-

-
+

How would you like the mouse-over thumbnails on the front page to load when using List Mode?

+
+

+

+
-

You can set a default thumbnail configuration for all galleries you visit.

-
- +

You can set a default thumbnail configuration for all galleries you visit.

+
+
- - + + - - + + -
Size: -
-
-
Size: +
+
+
Rows: -
-
-
Rows: +
+
+
-
+ +

Thumbnail Scaling

- - - - - -
%Thumbnails on the thumbnail and extended gallery list views can be scaled to a custom value between 75% and 150%.
+ + + + + +
%Thumbnails on the thumbnail and extended gallery list views can be scaled to a custom value between 75% and 150%.

Viewport Override

- - - - - -
pxAllows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100% thumbnail scale are between 640 and 1400.
+ + + + + +
pxAllows you to override the virtual width of the site for mobile devices. This is normally determined automatically by your device based on its DPI. Sensible values at 100% thumbnail scale are between 640 and 1400.

Gallery Comments

-

Sort order for gallery comments:

-
-

-

-

-
+

Sort order for gallery comments:

+
+

+

+

+
-

Show gallery comment votes:

-
-

-

-
+

Show gallery comment votes:

+
+

+

+

Gallery Tags

-

Sort order for gallery tags:

-
-

-

-
+

Sort order for gallery tags:

+
+

+

+

Gallery Page Numbering

-

Show gallery page numbers: -

-

-

-
+

Show gallery page numbers: +

+

+

+

Original Images

-

Use original images instead of the resampled versions where applicable?

-
-

-

-
+

Use original images instead of the resampled versions where available?

+
+

+

+

Multi-Page Viewer

-

Always use the Multi-Page Viewer? There will still be a link to manually start it if this is left disabled.

-
-

-

-
+

Always use the Multi-Page Viewer? There will still be a link to manually start it if this is left disabled.

+
+

+

+
-

Multi-Page Viewer Display Style:

-
-

-

-

-
+

Multi-Page Viewer Display Style:

+
+

+

+

+
-

Multi-Page Viewer Thumbnail Pane:

-
-

-

-
+

Multi-Page Viewer Thumbnail Pane:

+
+

+

+
- + diff --git a/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift b/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift index be16ed23..79a670f8 100644 --- a/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift +++ b/EhPandaTests/Tests/Parser/Other/EhSettingParserTests.swift @@ -24,13 +24,13 @@ class EhSettingParserTests: XCTestCase, TestHelper { let ehProfile1 = profiles[0] XCTAssertEqual(ehProfile1.value, 1) XCTAssertEqual(ehProfile1.name, "Default Profile") - XCTAssertEqual(ehProfile1.isSelected, true) + XCTAssertEqual(ehProfile1.isSelected, false) XCTAssertEqual(ehProfile1.isDefault, true) let ehProfile2 = profiles[1] XCTAssertEqual(ehProfile2.value, 2) XCTAssertEqual(ehProfile2.name, "EhPanda") - XCTAssertEqual(ehProfile2.isSelected, false) + XCTAssertEqual(ehProfile2.isSelected, true) XCTAssertEqual(ehProfile2.isDefault, false) XCTAssertTrue(EhSetting.verifyEhPandaProfileName(with: ehProfile2.name)) } @@ -42,8 +42,8 @@ class EhSettingParserTests: XCTestCase, TestHelper { XCTAssertEqual(ehSetting.capableImageResolution, .x2400) XCTAssertEqual(ehSetting.capableImageResolutions, EhSetting.ImageResolution.allCases) - XCTAssertEqual(ehSetting.capableSearchResultCount, .twoHundred) - XCTAssertEqual(ehSetting.capableSearchResultCounts, EhSetting.SearchResultCount.allCases) + XCTAssertEqual(ehSetting.capableSearchResultCount, .oneHundred) + XCTAssertEqual(ehSetting.capableSearchResultCounts, [.twentyFive, .fifty, .oneHundred]) XCTAssertEqual(ehSetting.capableThumbnailConfigSize, .large) XCTAssertEqual(ehSetting.capableThumbnailConfigSizes, EhSetting.ThumbnailSize.allCases) @@ -62,6 +62,7 @@ class EhSettingParserTests: XCTestCase, TestHelper { XCTAssertEqual(ehSetting.galleryName, .japanese) XCTAssertEqual(ehSetting.archiverBehavior, .manualSelectManualStart) XCTAssertEqual(ehSetting.displayMode, .compact) + XCTAssertEqual(ehSetting.showSearchRangeIndicator, true) XCTAssertEqual(ehSetting.disabledCategories, .init(repeating: false, count: 10)) XCTAssertEqual(ehSetting.favoriteCategories, [ "Favorites 0", "Favorites 1", "Favorites 2", "Favorites 3", "Favorites 4", @@ -69,14 +70,13 @@ class EhSettingParserTests: XCTestCase, TestHelper { ]) XCTAssertEqual(ehSetting.favoritesSortOrder, .favoritedTime) XCTAssertEqual(ehSetting.ratingsColor, "") - XCTAssertEqual(ehSetting.excludedNamespaces, .init(repeating: false, count: 11)) XCTAssertEqual(ehSetting.tagFilteringThreshold, 0) XCTAssertEqual(ehSetting.tagWatchingThreshold, 0) XCTAssertEqual(ehSetting.excludedLanguages, .init(repeating: false, count: 50)) XCTAssertEqual(ehSetting.excludedUploaders, "") - XCTAssertEqual(ehSetting.searchResultCount, .twoHundred) + XCTAssertEqual(ehSetting.searchResultCount, .twentyFive) XCTAssertEqual(ehSetting.thumbnailLoadTiming, .onMouseOver) - XCTAssertEqual(ehSetting.thumbnailConfigSize, .normal) + XCTAssertEqual(ehSetting.thumbnailConfigSize, .large) XCTAssertEqual(ehSetting.thumbnailConfigRows, .four) XCTAssertEqual(ehSetting.thumbnailScaleFactor, 100) XCTAssertEqual(ehSetting.viewportVirtualWidth, 0) diff --git a/EhPandaTests/Tests/Store/Setting/AccountSettingStoreTests.swift b/EhPandaTests/Tests/Store/Setting/AccountSettingStoreTests.swift deleted file mode 100644 index 6e9fb34c..00000000 --- a/EhPandaTests/Tests/Store/Setting/AccountSettingStoreTests.swift +++ /dev/null @@ -1,239 +0,0 @@ -// -// AccountSettingStoreTests.swift -// EhPandaTests -// -// Created by 荒木辰造 on R 4/03/06. -// - -import XCTest -@testable import EhPanda -import ComposableArchitecture - -class AccountSettingStoreTests: XCTestCase { - private var noopEnvironment: AccountSettingEnvironment { - .init( - hapticClient: .noop, - cookiesClient: .noop, - clipboardClient: .noop, - uiApplicationClient: .noop - ) - } - private static func getCookiesState(with value: String) -> (CookiesState, CookiesState) { - let ehCookiesState = CookiesState( - host: .ehentai, - igneous: .init(key: Defaults.Cookie.igneous, value: .init(rawValue: value, localizedString: .init()), editingText: value), - memberID: .init(key: Defaults.Cookie.ipbMemberId, value: .init(rawValue: value, localizedString: .init()), editingText: value), - passHash: .init(key: Defaults.Cookie.ipbPassHash, value: .init(rawValue: value, localizedString: .init()), editingText: value) - ) - let exCookiesState = CookiesState( - host: .exhentai, - igneous: .init(key: Defaults.Cookie.igneous, value: .init(rawValue: value, localizedString: .init()), editingText: value), - memberID: .init(key: Defaults.Cookie.ipbMemberId, value: .init(rawValue: value, localizedString: .init()), editingText: value), - passHash: .init(key: Defaults.Cookie.ipbPassHash, value: .init(rawValue: value, localizedString: .init()), editingText: value) - ) - return (ehCookiesState, exCookiesState) - } - private static func setCookies(with state: CookiesState) { - _ = CookiesClient.live.setCookies(state: state).sink(receiveValue: { _ in }) - } - @discardableResult private static func teardownCookies(value: String? = nil) -> String { - let initialValue = UUID().uuidString - let (initialEhCookiesState, initialExCookiesState) = getCookiesState(with: value ?? initialValue) - setCookies(with: initialEhCookiesState) - setCookies(with: initialExCookiesState) - return value ?? initialValue - } - - override class func tearDown() { - super.tearDown() - - teardownCookies(value: .init()) - } - - func testCookies(with value: String) throws { - [Defaults.Cookie.igneous, Defaults.Cookie.ipbMemberId, Defaults.Cookie.ipbPassHash] - .flatMap({ key in [Defaults.URL.ehentai, Defaults.URL.exhentai].map({ ($0, key) }) }) - .map(CookiesClient.live.getCookie) - .forEach({ XCTAssertEqual($0, .init(rawValue: value, localizedString: .init())) }) - } - - func testBinding() throws { - let store = TestStore( - initialState: AccountSettingState( - route: .ehSetting, - ehCookiesState: .empty(.exhentai), - exCookiesState: .empty(.ehentai), - loginState: .init(route: .webView(.mock)), - ehSettingState: .init(route: .deleteProfile) - ), - reducer: accountSettingReducer, - environment: AccountSettingEnvironment( - hapticClient: .noop, - cookiesClient: .live, - clipboardClient: .noop, - uiApplicationClient: .noop - ) - ) - - store.send(.set(\.$route, nil)) { - $0.route = nil - } - store.receive(.clearSubStates) { - $0.loginState = .init() - $0.ehSettingState = .init() - } - store.receive(.login(.teardown)) - store.receive(.ehSetting(.teardown)) - store.send(.set(\.$route, .login)) { - $0.route = .login - } - - AccountSettingStoreTests.teardownCookies() - - let testValue = UUID().uuidString - let (ehCookiesState, exCookiesState) = AccountSettingStoreTests.getCookiesState(with: testValue) - store.send(.set(\.$ehCookiesState, ehCookiesState)) { - $0.ehCookiesState = ehCookiesState - } - store.send(.set(\.$exCookiesState, exCookiesState)) { - $0.exCookiesState = exCookiesState - } - try testCookies(with: testValue) - } - - func testSetNavigation() throws { - let store = TestStore( - initialState: AccountSettingState( - route: .ehSetting, - loginState: .init(route: .webView(.mock)), - ehSettingState: .init(route: .deleteProfile) - ), - reducer: accountSettingReducer, - environment: noopEnvironment - ) - - store.send(.setNavigation(nil)) { - $0.route = nil - } - store.receive(.clearSubStates) { - $0.loginState = .init() - $0.ehSettingState = .init() - } - store.receive(.login(.teardown)) - store.receive(.ehSetting(.teardown)) - store.send(.setNavigation(.webView(.mock))) { - $0.route = .webView(.mock) - } - } - - func testOnLogoutConfirmButtonTapped() throws { - let store = TestStore( - initialState: AccountSettingState( - ehCookiesState: .empty(.exhentai), - exCookiesState: .empty(.ehentai) - ), - reducer: accountSettingReducer, - environment: AccountSettingEnvironment( - hapticClient: .noop, - cookiesClient: .live, - clipboardClient: .noop, - uiApplicationClient: .noop - ) - ) - - let initialValue = AccountSettingStoreTests.teardownCookies() - let (ehCookiesState, exCookiesState) = AccountSettingStoreTests.getCookiesState(with: initialValue) - store.send(.onLogoutConfirmButtonTapped) - store.receive(.loadCookies) { - $0.ehCookiesState = ehCookiesState - $0.exCookiesState = exCookiesState - } - try testCookies(with: initialValue) - } - - func testClearSubStates() throws { - let store = TestStore( - initialState: AccountSettingState( - loginState: .init(route: .webView(.mock)), - ehSettingState: .init(route: .deleteProfile) - ), - reducer: accountSettingReducer, - environment: noopEnvironment - ) - - store.send(.clearSubStates) { - $0.loginState = .init() - $0.ehSettingState = .init() - } - store.receive(.login(.teardown)) - store.receive(.ehSetting(.teardown)) - } - - func testLoadCookies() throws { - let store = TestStore( - initialState: AccountSettingState( - ehCookiesState: .empty(.exhentai), - exCookiesState: .empty(.ehentai) - ), - reducer: accountSettingReducer, - environment: noopEnvironment - ) - - store.send(.loadCookies) { - $0.ehCookiesState = .init( - host: .ehentai, - igneous: .init(key: Defaults.Cookie.igneous, value: .empty), - memberID: .init(key: Defaults.Cookie.ipbMemberId, value: .empty), - passHash: .init(key: Defaults.Cookie.ipbPassHash, value: .empty) - ) - $0.exCookiesState = .init( - host: .exhentai, - igneous: .init(key: Defaults.Cookie.igneous, value: .empty), - memberID: .init(key: Defaults.Cookie.ipbMemberId, value: .empty), - passHash: .init(key: Defaults.Cookie.ipbPassHash, value: .empty) - ) - } - } - - func testCopyCookies() throws { - let store = TestStore( - initialState: AccountSettingState( - route: .ehSetting - ), - reducer: accountSettingReducer, - environment: noopEnvironment - ) - - store.send(.copyCookies(.ehentai)) - store.receive(.setNavigation(.hud)) { - $0.route = .hud - } - } - - func testLoginLoginDone() throws { - let store = TestStore( - initialState: AccountSettingState( - route: .ehSetting, - loginState: .init(route: .webView(.mock), loginState: .loading), - ehSettingState: .init(route: .deleteProfile) - ), - reducer: accountSettingReducer, - environment: noopEnvironment - ) - - store.send(.login(.loginDone(.success(nil)))) { - $0.loginState = .init(route: nil, loginState: .idle) - } - if noopEnvironment.cookiesClient.didLogin { - store.receive(.setNavigation(nil)) { - $0.route = nil - } - store.receive(.clearSubStates) { - $0.loginState = .init() - $0.ehSettingState = .init() - } - store.receive(.login(.teardown)) - store.receive(.ehSetting(.teardown)) - } - } -} diff --git a/EhPandaTests/Tests/Store/Setting/AppearanceSettingStoreTests.swift b/EhPandaTests/Tests/Store/Setting/AppearanceSettingStoreTests.swift deleted file mode 100644 index 1c072520..00000000 --- a/EhPandaTests/Tests/Store/Setting/AppearanceSettingStoreTests.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// AppearanceSettingStoreTests.swift -// EhPandaTests -// -// Created by 荒木辰造 on R 4/03/26. -// - -import XCTest -@testable import EhPanda -import ComposableArchitecture - -class AppearanceSettingStoreTests: XCTestCase { - private var noopEnvironment: AppearanceSettingEnvironment { - .init() - } - - func testBinding() throws { - let store = TestStore( - initialState: AppearanceSettingState( - route: .appIcon - ), - reducer: appearanceSettingReducer, - environment: noopEnvironment - ) - store.send(.set(\.$route, nil)) { - $0.route = nil - } - } - - func testSetNavigation() throws { - let store = TestStore( - initialState: AppearanceSettingState( - route: .appIcon - ), - reducer: appearanceSettingReducer, - environment: noopEnvironment - ) - store.send(.setNavigation(nil)) { - $0.route = nil - } - } -} diff --git a/EhPandaTests/Tests/Store/Setting/GeneralSettingStoreTests.swift b/EhPandaTests/Tests/Store/Setting/GeneralSettingStoreTests.swift deleted file mode 100644 index 08d5281a..00000000 --- a/EhPandaTests/Tests/Store/Setting/GeneralSettingStoreTests.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// GeneralSettingStoreTests.swift -// EhPandaTests -// -// Created by 荒木辰造 on R 4/03/24. -// - -import XCTest -@testable import EhPanda -import ComposableArchitecture - -class GeneralSettingStoreTests: XCTestCase { - private var noopEnvironment: GeneralSettingEnvironment { - .init( - fileClient: .noop, - loggerClient: .noop, - libraryClient: .noop, - databaseClient: .noop, - uiApplicationClient: .noop, - authorizationClient: .noop - ) - } - - func testBinding() throws { - let store = TestStore( - initialState: GeneralSettingState( - route: .clearCache, - logsState: .init(route: .log( - .init(fileName: .init(), contents: .init()) - )) - ), - reducer: generalSettingReducer, - environment: noopEnvironment - ) - - store.send(.set(\.$route, nil)) { - $0.route = nil - } - store.receive(.clearSubStates) { - $0.logsState = .init() - } - store.receive(.logs(.teardown)) - store.send(.set(\.$route, .logs)) { - $0.route = .logs - } - } - - func testSetNavigation() throws { - let store = TestStore( - initialState: GeneralSettingState( - route: .clearCache, - logsState: .init(route: .log( - .init(fileName: .init(), contents: .init()) - )) - ), - reducer: generalSettingReducer, - environment: noopEnvironment - ) - - store.send(.setNavigation(nil)) { - $0.route = nil - } - store.receive(.clearSubStates) { - $0.logsState = .init() - } - store.receive(.logs(.teardown)) - store.send(.set(\.$route, .logs)) { - $0.route = .logs - } - } - - func testClearSubStates() throws { - let store = TestStore( - initialState: GeneralSettingState( - logsState: .init(route: .log( - .init(fileName: .init(), contents: .init()) - )) - ), - reducer: generalSettingReducer, - environment: noopEnvironment - ) - - store.send(.clearSubStates) { - $0.logsState = .init() - } - store.receive(.logs(.teardown)) - } - - func testClearWebImageCache() throws { - let store = TestStore( - initialState: GeneralSettingState(), - reducer: generalSettingReducer, - environment: GeneralSettingEnvironment( - fileClient: .noop, - loggerClient: .noop, - libraryClient: .live, - databaseClient: .live, - uiApplicationClient: .noop, - authorizationClient: .noop - ) - ) - store.send(.clearWebImageCache) - XCTWaiter.wait(timeout: 5) - store.receive(.calculateWebImageDiskCache) - store.receive(.calculateWebImageDiskCacheDone(0)) { - let formatter = ByteCountFormatter() - formatter.allowedUnits = .useAll - $0.diskImageCacheSize = formatter.string(fromByteCount: 0) - } - } -} diff --git a/README.md b/README.md index 60c50b41..3906d699 100644 --- a/README.md +++ b/README.md @@ -44,4 +44,4 @@ The content in this application is derived from E-Hentai, which is user-generate https://ehpanda.app ## App Icon -Copyright © 2022 荒木辰造. All rights reserved. +Copyright © 2023 荒木辰造. All rights reserved. diff --git a/READMEs/README.chs.md b/READMEs/README.chs.md index 806bc0df..f643a643 100644 --- a/READMEs/README.chs.md +++ b/READMEs/README.chs.md @@ -44,4 +44,4 @@ https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/b https://ehpanda.app ## 应用程序图标 -Copyright © 2022 荒木辰造. All rights reserved. +Copyright © 2023 荒木辰造. All rights reserved. diff --git a/READMEs/README.cht.md b/READMEs/README.cht.md index 57da354b..f60435b9 100644 --- a/READMEs/README.cht.md +++ b/READMEs/README.cht.md @@ -44,4 +44,4 @@ https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/b https://ehpanda.app ## 應用程式圖示 -Copyright © 2022 荒木辰造. All rights reserved. +Copyright © 2023 荒木辰造. All rights reserved. diff --git a/READMEs/README.de.md b/READMEs/README.de.md index 3cf04e80..6e6056ad 100644 --- a/READMEs/README.de.md +++ b/READMEs/README.de.md @@ -44,4 +44,4 @@ Der Inhalt der von dieser App verwaltet wird, wird von E-Hentai geladen. Hierbei https://ehpanda.app ## App Icon -Copyright © 2022 荒木辰造. All rights reserved. +Copyright © 2023 荒木辰造. All rights reserved. diff --git a/READMEs/README.jpn.md b/READMEs/README.jpn.md index 96cc4d07..f645538d 100644 --- a/READMEs/README.jpn.md +++ b/READMEs/README.jpn.md @@ -44,4 +44,4 @@ https://ehpanda.app: [main.js](https://github.com/EhPanda-Team/ehpanda-website/b https://ehpanda.app ## アプリアイコン -Copyright © 2022 荒木辰造. All rights reserved. +Copyright © 2023 荒木辰造. All rights reserved. diff --git a/READMEs/README.ko.md b/READMEs/README.ko.md index ec368209..f7414a9d 100644 --- a/READMEs/README.ko.md +++ b/READMEs/README.ko.md @@ -44,4 +44,4 @@ iOS / iPadOS 버전이 16.0 이상인지 확인해주세요. https://ehpanda.app ## 앱 아이콘 -Copyright © 2022 荒木辰造. All rights reserved. +Copyright © 2023 荒木辰造. All rights reserved.