diff --git a/.swiftlint.yml b/.swiftlint.yml index 22adb80e..9c0d813d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -12,6 +12,8 @@ identifier_name: - y - id - no + - mo excluded: - EhPandaTests + - EhPanda/App/Generated diff --git a/EhPanda.xcodeproj/project.pbxproj b/EhPanda.xcodeproj/project.pbxproj index 3cbb43a0..e458c1a4 100644 --- a/EhPanda.xcodeproj/project.pbxproj +++ b/EhPanda.xcodeproj/project.pbxproj @@ -116,7 +116,7 @@ AB6DE897268822390087C579 /* LogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6DE896268822390087C579 /* LogsView.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 */; }; + AB706F80278981370025A48A /* AlertKit+.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F7F278981370025A48A /* AlertKit+.swift */; }; AB706F82278986120025A48A /* ToolbarItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F81278986120025A48A /* ToolbarItems.swift */; }; AB706F842789AD2D0025A48A /* ToplistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F832789AD2D0025A48A /* ToplistsView.swift */; }; AB706F862789AD490025A48A /* ToplistsReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB706F852789AD490025A48A /* ToplistsReducer.swift */; }; @@ -155,7 +155,7 @@ 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 */; }; + AB7BF2DA27AA78CF001865A3 /* Reducer+.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2D927AA78CF001865A3 /* Reducer+.swift */; }; AB7BF2FB27ABCA3A001865A3 /* MigrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2FA27ABCA3A001865A3 /* MigrationView.swift */; }; AB7BF2FD27ABCAD4001865A3 /* MigrationReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2FC27ABCAD4001865A3 /* MigrationReducer.swift */; }; AB7BF30727ABDFF1001865A3 /* CoreDataMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7BF2FE27ABDFF1001865A3 /* CoreDataMigrator.swift */; }; @@ -187,8 +187,8 @@ ABAC82FE26BC4A96009F5026 /* OpenCC in Frameworks */ = {isa = PBXBuildFile; productRef = ABAC82FD26BC4A96009F5026 /* OpenCC */; }; ABBB2631278E6EF3007B6149 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2630278E6EF3007B6149 /* SearchView.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 */; }; + ABBB2638278FBD2F007B6149 /* SwiftUINavigation+.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB2637278FBD2F007B6149 /* SwiftUINavigation+.swift */; }; + ABBB263A2792588F007B6149 /* TTProgressHUD+.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABBB26392792588F007B6149 /* TTProgressHUD+.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 */; }; @@ -271,6 +271,8 @@ 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 */; }; + EAC831E02B66298900B518D5 /* CoreData+.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC831DF2B66298900B518D5 /* CoreData+.swift */; }; + EAC831E22B662BB300B518D5 /* NSPredicate+.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC831E12B662BB300B518D5 /* NSPredicate+.swift */; }; EAE63E2129E2A6330048C601 /* SwiftyBeaver in Frameworks */ = {isa = PBXBuildFile; productRef = EAE63E2029E2A6330048C601 /* SwiftyBeaver */; }; /* End PBXBuildFile section */ @@ -412,7 +414,7 @@ AB6DE896268822390087C579 /* LogsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsView.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 = ""; }; + AB706F7F278981370025A48A /* AlertKit+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AlertKit+.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 /* ToplistsReducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToplistsReducer.swift; sourceTree = ""; }; @@ -452,7 +454,7 @@ 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 = ""; }; + AB7BF2D927AA78CF001865A3 /* Reducer+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Reducer+.swift"; sourceTree = ""; }; AB7BF2FA27ABCA3A001865A3 /* MigrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationView.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 = ""; }; @@ -487,8 +489,8 @@ 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 = ""; }; - 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 = ""; }; + ABBB2637278FBD2F007B6149 /* SwiftUINavigation+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftUINavigation+.swift"; sourceTree = ""; }; + ABBB26392792588F007B6149 /* TTProgressHUD+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TTProgressHUD+.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 = ""; }; @@ -581,6 +583,8 @@ 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 = ""; }; + EAC831DF2B66298900B518D5 /* CoreData+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CoreData+.swift"; sourceTree = ""; }; + EAC831E12B662BB300B518D5 /* NSPredicate+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPredicate+.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -900,10 +904,12 @@ children = ( ABA732D825A8018A00B3D9AB /* Extensions.swift */, ABC3C7762593699A00E0C11B /* ViewModifiers.swift */, - AB706F7F278981370025A48A /* AlertKit_Extension.swift */, - AB7BF2D927AA78CF001865A3 /* Reducer_Extension.swift */, - ABBB26392792588F007B6149 /* TTProgressHUD_Extension.swift */, - ABBB2637278FBD2F007B6149 /* SwiftUINavigation_Extension.swift */, + AB706F7F278981370025A48A /* AlertKit+.swift */, + AB7BF2D927AA78CF001865A3 /* Reducer+.swift */, + ABBB26392792588F007B6149 /* TTProgressHUD+.swift */, + ABBB2637278FBD2F007B6149 /* SwiftUINavigation+.swift */, + EAC831DF2B66298900B518D5 /* CoreData+.swift */, + EAC831E12B662BB300B518D5 /* NSPredicate+.swift */, ); path = Extensions; sourceTree = ""; @@ -1790,6 +1796,7 @@ AB1FA94D27CA1F140063EF55 /* TagTranslation.swift in Sources */, ABC8355D27B118330091DCDB /* DetailSearchView.swift in Sources */, ABBB264227942B74007B6149 /* URLClient.swift in Sources */, + EAC831E02B66298900B518D5 /* CoreData+.swift in Sources */, AB0CFBD527C24B3B004BD372 /* MarkdownUtil.swift in Sources */, ABF45AF625F3313D00ECB568 /* AppearanceSettingView.swift in Sources */, AB7BF2CE27AA3E58001865A3 /* AppUtil.swift in Sources */, @@ -1840,7 +1847,7 @@ AB0929C027805A8200F107CA /* LoginReducer.swift in Sources */, ABBB2631278E6EF3007B6149 /* SearchView.swift in Sources */, AB706F92278A6E8C0025A48A /* WatchedReducer.swift in Sources */, - AB706F80278981370025A48A /* AlertKit_Extension.swift in Sources */, + AB706F80278981370025A48A /* AlertKit+.swift in Sources */, ABA9A6C228EC7BD000EE28DE /* Strings.swift in Sources */, AB58A5B22776B99000C0D285 /* AppReducer.swift in Sources */, AB24C566276758E30085C33A /* GalleryCardCell.swift in Sources */, @@ -1865,9 +1872,9 @@ ABF45AF025F3313D00ECB568 /* CommentsView.swift in Sources */, ABC8356527B36E550091DCDB /* AutoPlayHandler.swift in Sources */, ABBB2671279AFA61007B6149 /* EnvironmentKeys.swift in Sources */, - AB7BF2DA27AA78CF001865A3 /* Reducer_Extension.swift in Sources */, + AB7BF2DA27AA78CF001865A3 /* Reducer+.swift in Sources */, ABBD2B602768D7AD0072AED2 /* GalleryRankingCell.swift in Sources */, - ABBB263A2792588F007B6149 /* TTProgressHUD_Extension.swift in Sources */, + ABBB263A2792588F007B6149 /* TTProgressHUD+.swift in Sources */, AB7BF2D627AA3F4C001865A3 /* FileUtil.swift in Sources */, AB0ABCB726C541A400AD970F /* WaveForm.swift in Sources */, AB0929D62782A65F00F107CA /* GeneralSettingReducer.swift in Sources */, @@ -1914,12 +1921,13 @@ ABA732DF25A852D800B3D9AB /* Filter.swift in Sources */, AB7BF31E27ABE028001865A3 /* NSPersistentStoreCoordinator+SQLite.swift in Sources */, ABC1FAB82642C37D00A9F352 /* NewDawnView.swift in Sources */, + EAC831E22B662BB300B518D5 /* NSPredicate+.swift in Sources */, ABC732C527B9024500D47DA9 /* LiveText.swift in Sources */, ABF45AE825F3313D00ECB568 /* LinkedText.swift in Sources */, ABC732C727B90F0900D47DA9 /* LiveTextView.swift in Sources */, AB0929B6277F043D00F107CA /* AccountSettingReducer.swift in Sources */, ABD49D67277EAC90003D1A07 /* URLUtil.swift in Sources */, - ABBB2638278FBD2F007B6149 /* SwiftUINavigation_Extension.swift in Sources */, + ABBB2638278FBD2F007B6149 /* SwiftUINavigation+.swift in Sources */, AB10117E26986B7D00C2C1A9 /* GalleryStateMO+CoreDataProperties.swift in Sources */, AB26F59627ACCA1800AB3468 /* AppEnv.swift in Sources */, ABF45AE525F3313D00ECB568 /* PostCommentView.swift in Sources */, diff --git a/EhPanda/App/Tools/Clients/AppDelegateClient.swift b/EhPanda/App/Tools/Clients/AppDelegateClient.swift index a904cca7..7d4391c9 100644 --- a/EhPanda/App/Tools/Clients/AppDelegateClient.swift +++ b/EhPanda/App/Tools/Clients/AppDelegateClient.swift @@ -9,31 +9,27 @@ import SwiftUI import ComposableArchitecture struct AppDelegateClient { - let setOrientation: (UIInterfaceOrientationMask) -> EffectTask - let setOrientationMask: (UIInterfaceOrientationMask) -> EffectTask + let setOrientation: (UIInterfaceOrientationMask) -> Void + let setOrientationMask: (UIInterfaceOrientationMask) -> Void } extension AppDelegateClient { static let live: Self = .init( setOrientation: { mask in - .fireAndForget { - DeviceUtil.keyWindow?.windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) - } + DeviceUtil.keyWindow?.windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) }, setOrientationMask: { mask in - .fireAndForget { - AppDelegate.orientationMask = mask - } + AppDelegate.orientationMask = mask } ) - func setPortraitOrientation() -> EffectTask { + func setPortraitOrientation() { setOrientation(.portrait) } - func setAllOrientationMask() -> EffectTask { + func setAllOrientationMask() { setOrientationMask([.all]) } - func setPortraitOrientationMask() -> EffectTask { + func setPortraitOrientationMask() { setOrientationMask([.portrait, .portraitUpsideDown]) } } @@ -55,8 +51,8 @@ extension DependencyValues { // MARK: Test extension AppDelegateClient { static let noop: Self = .init( - setOrientation: { _ in .none }, - setOrientationMask: { _ in .none } + setOrientation: { _ in }, + setOrientationMask: { _ in } ) static let unimplemented: Self = .init( diff --git a/EhPanda/App/Tools/Clients/AuthorizationClient.swift b/EhPanda/App/Tools/Clients/AuthorizationClient.swift index 442811fe..dfa2a36b 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) -> EffectTask + let localAuthroize: (String) async -> Bool } extension AuthorizationClient { @@ -21,21 +21,14 @@ extension AuthorizationClient { return !LAContext().canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) }, localAuthroize: { reason in - Future { promise in - let context = LAContext() - var error: NSError? + let context = LAContext() + var error: NSError? - if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { - context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { isSuccess, _ in - promise(.success(isSuccess)) - } - } else { - promise(.success(false)) - } + if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { + return (try? await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason)) ?? false + } else { + return false } - .eraseToAnyPublisher() - .receive(on: DispatchQueue.main) - .eraseToEffect() } ) } @@ -58,7 +51,7 @@ extension DependencyValues { extension AuthorizationClient { static let noop: Self = .init( passcodeNotSet: { false }, - localAuthroize: { _ in .none } + localAuthroize: { _ in false } ) static let unimplemented: Self = .init( diff --git a/EhPanda/App/Tools/Clients/ClipboardClient.swift b/EhPanda/App/Tools/Clients/ClipboardClient.swift index 4c72c63f..0b4a312d 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) -> EffectTask - let saveImage: (UIImage, Bool) -> EffectTask + let saveText: (String) -> Void + let saveImage: (UIImage, Bool) -> Void } extension ClipboardClient { @@ -29,21 +29,17 @@ extension ClipboardClient { UIPasteboard.general.changeCount }, saveText: { text in - .fireAndForget { - UIPasteboard.general.string = text - } + UIPasteboard.general.string = text }, saveImage: { (image, isAnimated) in - .fireAndForget { - if isAnimated { - DispatchQueue.global(qos: .utility).async { - if let data = image.kf.data(format: .GIF) { - UIPasteboard.general.setData(data, forPasteboardType: UTType.gif.identifier) - } + if isAnimated { + DispatchQueue.global(qos: .utility).async { + if let data = image.kf.data(format: .GIF) { + UIPasteboard.general.setData(data, forPasteboardType: UTType.gif.identifier) } - } else { - UIPasteboard.general.image = image } + } else { + UIPasteboard.general.image = image } } ) @@ -68,8 +64,8 @@ extension ClipboardClient { static let noop: Self = .init( url: { nil }, changeCount: { 0 }, - saveText: { _ in .none }, - saveImage: { _, _ in .none } + saveText: { _ in }, + saveImage: { _, _ in } ) static let unimplemented: Self = .init( diff --git a/EhPanda/App/Tools/Clients/CookieClient.swift b/EhPanda/App/Tools/Clients/CookieClient.swift index 86143d11..b583917a 100644 --- a/EhPanda/App/Tools/Clients/CookieClient.swift +++ b/EhPanda/App/Tools/Clients/CookieClient.swift @@ -9,7 +9,7 @@ import Foundation import ComposableArchitecture struct CookieClient { - let clearAll: () -> EffectTask + let clearAll: () -> Void let getCookie: (URL, String) -> CookieValue private let removeCookie: (URL, String) -> Void private let checkExistence: (URL, String) -> Bool @@ -19,11 +19,9 @@ struct CookieClient { extension CookieClient { static let live: Self = .init( clearAll: { - .fireAndForget { - if let historyCookies = HTTPCookieStorage.shared.cookies { - historyCookies.forEach { - HTTPCookieStorage.shared.deleteCookie($0) - } + if let historyCookies = HTTPCookieStorage.shared.cookies { + historyCookies.forEach { + HTTPCookieStorage.shared.deleteCookie($0) } } }, @@ -108,13 +106,11 @@ extension CookieClient { guard let cookie = newCookie else { return } HTTPCookieStorage.shared.setCookie(cookie) } - func setOrEditCookie(for url: URL, key: String, value: String) -> EffectTask { - .fireAndForget { - if checkExistence(url, key) { - editCookie(for: url, key: key, value: value) - } else { - setCookie(for: url, key: key, value: value) - } + func setOrEditCookie(for url: URL, key: String, value: String) { + if checkExistence(url, key) { + editCookie(for: url, key: key, value: value) + } else { + setCookie(for: url, key: key, value: value) } } } @@ -138,35 +134,29 @@ extension CookieClient { && !getCookie(url, Defaults.Cookie.ipbPassHash).rawValue.isEmpty && getCookie(url, Defaults.Cookie.igneous).rawValue.isEmpty } - func removeYay() -> EffectTask { - .fireAndForget { - removeCookie(Defaults.URL.exhentai, Defaults.Cookie.yay) - removeCookie(Defaults.URL.sexhentai, Defaults.Cookie.yay) - } + func removeYay() { + removeCookie(Defaults.URL.exhentai, Defaults.Cookie.yay) + removeCookie(Defaults.URL.sexhentai, Defaults.Cookie.yay) } - 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 syncExCookies() { + [ + Defaults.Cookie.ipbMemberId, + Defaults.Cookie.ipbPassHash, + Defaults.Cookie.igneous + ] + .forEach { + 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 ignoreOffensive() { + setOrEditCookie(for: Defaults.URL.ehentai, key: Defaults.Cookie.ignoreOffensive, value: "1") + setOrEditCookie(for: Defaults.URL.exhentai, key: Defaults.Cookie.ignoreOffensive, value: "1") } - func fulfillAnotherHostField() -> EffectTask { + func fulfillAnotherHostField() { let ehURL = Defaults.URL.ehentai let exURL = Defaults.URL.exhentai let memberIdKey = Defaults.Cookie.ipbMemberId @@ -177,17 +167,11 @@ extension CookieClient { let exPassHash = getCookie(exURL, passHashKey).rawValue if !ehMemberId.isEmpty && !ehPassHash.isEmpty && (exMemberId.isEmpty || exPassHash.isEmpty) { - return .merge( - setOrEditCookie(for: exURL, key: memberIdKey, value: ehMemberId), - setOrEditCookie(for: exURL, key: passHashKey, value: ehPassHash) - ) + setOrEditCookie(for: exURL, key: memberIdKey, value: ehMemberId) + setOrEditCookie(for: exURL, key: passHashKey, value: ehPassHash) } else if !exMemberId.isEmpty && !exPassHash.isEmpty && (ehMemberId.isEmpty || ehPassHash.isEmpty) { - return .merge( - setOrEditCookie(for: ehURL, key: memberIdKey, value: exMemberId), - setOrEditCookie(for: ehURL, key: passHashKey, value: exPassHash) - ) - } else { - return .none + setOrEditCookie(for: ehURL, key: memberIdKey, value: exMemberId) + setOrEditCookie(for: ehURL, key: passHashKey, value: exPassHash) } } func loadCookiesState(host: GalleryHost) -> CookiesState { @@ -218,11 +202,11 @@ extension CookieClient { // MARK: SetCookies extension CookieClient { - func setCookies(state: CookiesState, trimsSpaces: Bool = true) -> EffectTask { - let effects: [EffectTask] = state.allCases - .flatMap { subState in + func setCookies(state: CookiesState, trimsSpaces: Bool = true) { + state.allCases + .forEach { subState in state.host.cookieURLs - .map { + .forEach { setOrEditCookie( for: $0, key: subState.key, @@ -231,42 +215,37 @@ extension CookieClient { ) } } - return effects.isEmpty ? .none : .merge(effects) } - func setCredentials(response: HTTPURLResponse) -> EffectTask { - .fireAndForget { - guard let setString = response.allHeaderFields["Set-Cookie"] as? String else { return } - setString.components(separatedBy: ", ") - .flatMap { $0.components(separatedBy: "; ") }.forEach { value in - [Defaults.URL.ehentai, Defaults.URL.exhentai].forEach { url in - [ - Defaults.Cookie.ipbMemberId, - Defaults.Cookie.ipbPassHash, - Defaults.Cookie.igneous - ].forEach { key in - guard !(url == Defaults.URL.ehentai && key == Defaults.Cookie.igneous), - let range = value.range(of: "\(key)=") else { return } - setCookie(for: url, key: key, value: String(value[range.upperBound...])) - } + func setCredentials(response: HTTPURLResponse) { + guard let setString = response.allHeaderFields["Set-Cookie"] as? String else { return } + setString.components(separatedBy: ", ") + .flatMap { $0.components(separatedBy: "; ") }.forEach { value in + [Defaults.URL.ehentai, Defaults.URL.exhentai].forEach { url in + [ + Defaults.Cookie.ipbMemberId, + Defaults.Cookie.ipbPassHash, + Defaults.Cookie.igneous + ].forEach { key in + guard !(url == Defaults.URL.ehentai && key == Defaults.Cookie.igneous), + let range = value.range(of: "\(key)=") else { return } + setCookie(for: url, key: key, value: String(value[range.upperBound...])) } } - } + } } - func setSkipServer(response: HTTPURLResponse) -> EffectTask { - .fireAndForget { - guard let setString = response.allHeaderFields["Set-Cookie"] as? String else { return } - setString.components(separatedBy: ", ") - .flatMap { $0.components(separatedBy: "; ") } - .forEach { value in - let key = Defaults.Cookie.skipServer - if let range = value.range(of: "\(key)=") { - setCookie( - for: Defaults.URL.host, key: key, - value: String(value[range.upperBound...]), path: "/s/" - ) - } + func setSkipServer(response: HTTPURLResponse) { + guard let setString = response.allHeaderFields["Set-Cookie"] as? String else { return } + setString.components(separatedBy: ", ") + .flatMap { $0.components(separatedBy: "; ") } + .forEach { value in + let key = Defaults.Cookie.skipServer + if let range = value.range(of: "\(key)=") { + setCookie( + for: Defaults.URL.host, key: key, + value: String(value[range.upperBound...]), path: "/s/" + ) } - } + } } } @@ -287,7 +266,7 @@ extension DependencyValues { // MARK: Test extension CookieClient { static let noop: Self = .init( - clearAll: { .none }, + clearAll: {}, getCookie: { _, _ in .empty }, removeCookie: { _, _ in }, checkExistence: { _, _ in false }, diff --git a/EhPanda/App/Tools/Clients/DFClient.swift b/EhPanda/App/Tools/Clients/DFClient.swift index 54a900c5..449b9aae 100644 --- a/EhPanda/App/Tools/Clients/DFClient.swift +++ b/EhPanda/App/Tools/Clients/DFClient.swift @@ -10,23 +10,21 @@ import Kingfisher import ComposableArchitecture struct DFClient { - let setActive: (Bool) -> EffectTask + let setActive: (Bool) -> Void } extension DFClient { static let live: Self = .init( setActive: { newValue in - .fireAndForget { - if newValue { - URLProtocol.registerClass(DFURLProtocol.self) - } else { - URLProtocol.unregisterClass(DFURLProtocol.self) - } - // Kingfisher - let config = KingfisherManager.shared.downloader.sessionConfiguration - config.protocolClasses = newValue ? [DFURLProtocol.self] : nil - KingfisherManager.shared.downloader.sessionConfiguration = config + if newValue { + URLProtocol.registerClass(DFURLProtocol.self) + } else { + URLProtocol.unregisterClass(DFURLProtocol.self) } + // Kingfisher + let config = KingfisherManager.shared.downloader.sessionConfiguration + config.protocolClasses = newValue ? [DFURLProtocol.self] : nil + KingfisherManager.shared.downloader.sessionConfiguration = config } ) } @@ -48,7 +46,7 @@ extension DependencyValues { // MARK: Test extension DFClient { static let noop: Self = .init( - setActive: { _ in .none } + setActive: { _ in } ) static let unimplemented: Self = .init( diff --git a/EhPanda/App/Tools/Clients/DatabaseClient.swift b/EhPanda/App/Tools/Clients/DatabaseClient.swift index 574f0fb0..1660cadb 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: () -> EffectTask - let dropDatabase: () -> EffectTask + let prepareDatabase: () async -> AppError? + let dropDatabase: () async -> AppError? private let saveContext: () -> Void private let materializedObjects: (NSManagedObjectContext, NSPredicate) -> [NSManagedObject] } @@ -20,36 +20,10 @@ struct DatabaseClient { extension DatabaseClient { static let live: Self = .init( prepareDatabase: { - Future { promise in - PersistenceController.shared.prepare { - switch $0 { - case .success: - promise(.success(nil)) - - case .failure(let appError): - promise(.success(appError)) - } - } - } - .eraseToAnyPublisher() - .receive(on: DispatchQueue.main) - .eraseToEffect() + await PersistenceController.shared.prepare() }, dropDatabase: { - Future { promise in - PersistenceController.shared.rebuild { - switch $0 { - case .success: - promise(.success(nil)) - - case .failure(let appError): - promise(.success(appError)) - } - } - } - .eraseToAnyPublisher() - .receive(on: DispatchQueue.main) - .eraseToEffect() + await PersistenceController.shared.rebuild() }, saveContext: { let context = PersistenceController.shared.container.viewContext @@ -77,397 +51,381 @@ extension DatabaseClient { } // MARK: Foundation -extension DatabaseClient { - private func batchFetch( - entityType: MO.Type, fetchLimit: Int = 0, predicate: NSPredicate? = nil, - findBeforeFetch: Bool = true, sortDescriptors: [NSSortDescriptor]? = nil - ) -> [MO] { - var results = [MO]() - let context = PersistenceController.shared.container.viewContext - AppUtil.dispatchMainSync { - if findBeforeFetch, let predicate = predicate { - if let objects = materializedObjects(context, predicate) as? [MO], !objects.isEmpty { - results = objects - return - } - } - let request = NSFetchRequest( - entityName: String(describing: entityType) - ) - request.predicate = predicate - request.fetchLimit = fetchLimit - request.sortDescriptors = sortDescriptors - results = (try? context.fetch(request)) ?? [] +private extension DatabaseClient { + @discardableResult + func create( + entityType: MO.Type, + commitChanges: ((MO, NSManagedObjectContext) -> Void)? = nil + ) async -> MO { + let context = PersistenceController.shared.container.newBackgroundContext() + return await context.perform { + let newMO = entityType.init(context: context) + commitChanges?(newMO, context) + context.saveIfNeeded() + return newMO } - return results - } - - private func fetch( - entityType: MO.Type, predicate: NSPredicate? = nil, - findBeforeFetch: Bool = true, commitChanges: ((MO?) -> Void)? = nil - ) -> MO? { - let managedObject = batchFetch( - entityType: entityType, fetchLimit: 1, - predicate: predicate, findBeforeFetch: findBeforeFetch - ).first - commitChanges?(managedObject) - return managedObject - } - - private func fetchOrCreate( - entityType: MO.Type, predicate: NSPredicate? = nil, - commitChanges: ((MO?) -> Void)? = nil - ) -> MO { - if let storedMO = fetch( - entityType: entityType, predicate: predicate, commitChanges: commitChanges - ) { - return storedMO - } else { - let newMO = MO(context: PersistenceController.shared.container.viewContext) - commitChanges?(newMO) - saveContext() + } + + @discardableResult + func create( + model: Model, + commitChanges: ((Model.ManagedObject, NSManagedObjectContext) -> Void)? = nil + ) async -> Model.ManagedObject { + let context = PersistenceController.shared.container.newBackgroundContext() + return await context.perform { + let newMO = model.toManagedObject(in: context) + commitChanges?(newMO, context) + context.saveIfNeeded() return newMO } } - private func batchUpdate( - entityType: MO.Type, predicate: NSPredicate? = nil, commitChanges: ([MO]) -> Void - ) { - commitChanges(batchFetch( - entityType: entityType, - predicate: predicate, - findBeforeFetch: false - )) - saveContext() + func fetch( + entityType: MO.Type, + fetchLimit: Int = 0, + predicate: NSPredicate? = nil, + sortDescriptors: [NSSortDescriptor]? = nil + ) async -> [MO] { + let context = PersistenceController.shared.container.newBackgroundContext() + let request = NSFetchRequest(entityName: String(describing: entityType)) + request.predicate = predicate + request.fetchLimit = fetchLimit + request.sortDescriptors = sortDescriptors + return await context.perform { + (try? context.fetch(request)) ?? [] + } } - private func update( - entityType: MO.Type, predicate: NSPredicate? = nil, - createIfNil: Bool = false, commitChanges: (MO) -> Void - ) { - AppUtil.dispatchMainSync { - let storedMO: MO? - if createIfNil { - storedMO = fetchOrCreate(entityType: entityType, predicate: predicate) - } else { - storedMO = fetch(entityType: entityType, predicate: predicate) - } - if let storedMO = storedMO { - commitChanges(storedMO) - saveContext() + + func fetchModel( + entityType: MO.Type, + fetchLimit: Int = 0, + predicate: NSPredicate? = nil, + sortDescriptors: [NSSortDescriptor]? = nil + ) async -> [MO.Model] { + let context = PersistenceController.shared.container.newBackgroundContext() + let request = NSFetchRequest(entityName: String(describing: entityType)) + request.predicate = predicate + request.fetchLimit = fetchLimit + request.sortDescriptors = sortDescriptors + return await context.perform { + let result = (try? context.fetch(request)) ?? [] + return result.compactMap({ $0.toModel() }) + } + } + + func update( + entityType: MO.Type, + fetchLimit: Int = 0, + predicate: NSPredicate? = nil, + commitChanges: @escaping ([MO], NSManagedObjectContext) -> Void, + sortDescriptors: [NSSortDescriptor]? = nil + ) async { + let context = PersistenceController.shared.container.newBackgroundContext() + context.mergePolicy = NSOverwriteMergePolicy + let request = NSFetchRequest(entityName: String(describing: entityType)) + request.predicate = predicate + request.fetchLimit = fetchLimit + request.sortDescriptors = sortDescriptors + return await context.perform { + let result = (try? context.fetch(request)) ?? [] + commitChanges(result, context) + context.saveIfNeeded() + } + } + + func delete( + entityType: MO.Type, fetchLimit: Int = 0, predicate: NSPredicate? = nil + ) async { + let context = PersistenceController.shared.container.viewContext + + let request = NSFetchRequest(entityName: String(describing: entityType)) + request.predicate = predicate + request.fetchLimit = fetchLimit + let mos = context.performAndWait { + return (try? context.fetch(request)) ?? [] + } + + if let mo = mos.first { + await context.perform { + context.delete(mo) + context.saveIfNeeded() } } } } -// MARK: GalleryIdentifiable -extension DatabaseClient { - private func fetch( - entityType: MO.Type, gid: String, - findBeforeFetch: Bool = true, - commitChanges: ((MO?) -> Void)? = nil - ) -> MO? { - fetch( - entityType: entityType, predicate: NSPredicate(format: "gid == %@", gid), - findBeforeFetch: findBeforeFetch, commitChanges: commitChanges +// MARK: Accessor +private extension DatabaseClient { + func fetchFirst( + entityType: MO.Type, predicate: NSPredicate? = nil, sortDescriptors: [NSSortDescriptor]? = nil + ) async -> MO? { + await fetch( + entityType: entityType, + fetchLimit: 1, + predicate: predicate, + sortDescriptors: sortDescriptors ) + .first } - private func fetchOrCreate(entityType: MO.Type, gid: String) -> MO { - fetchOrCreate( + + func fetchFirstModel( + entityType: MO.Type, predicate: NSPredicate? = nil, sortDescriptors: [NSSortDescriptor]? = nil + ) async -> MO.Model? { + await fetchModel( entityType: entityType, - predicate: NSPredicate(format: "gid == %@", gid), - commitChanges: { $0?.gid = gid } + fetchLimit: 1, + predicate: predicate, + sortDescriptors: sortDescriptors ) + .first } - private func update( - entityType: MO.Type, gid: String, - createIfNil: Bool = false, - commitChanges: @escaping ((MO) -> Void) - ) { - AppUtil.dispatchMainSync { - let storedMO: MO? - if createIfNil { - storedMO = fetchOrCreate(entityType: entityType, gid: gid) - } else { - storedMO = fetch(entityType: entityType, gid: gid) - } - if let storedMO = storedMO { - commitChanges(storedMO) - saveContext() - } - } + + func updateFirst( + entityType: MO.Type, + predicate: NSPredicate? = nil, + commitChanges: @escaping (MO) -> Void, + sortDescriptors: [NSSortDescriptor]? = nil + ) async { + await update( + entityType: entityType, + fetchLimit: 1, + predicate: predicate, + commitChanges: { mos, _ in mos.first.map(commitChanges) }, + sortDescriptors: sortDescriptors + ) + } + + func deleteFirst(entityType: MO.Type, predicate: NSPredicate? = nil) async { + await delete(entityType: entityType, fetchLimit: 1, predicate: predicate) + } +} + +// MARK: GalleryIdentifiable +private extension DatabaseClient { + func fetchFirst( + entityType: MO.Type, gid: String, sortDescriptors: [NSSortDescriptor]? = nil + ) async -> MO? { + await fetchFirst(entityType: entityType, predicate: .init(gid: gid), sortDescriptors: sortDescriptors) + } + + func fetchFirstModel( + entityType: MO.Type, gid: String, sortDescriptors: [NSSortDescriptor]? = nil + ) async -> MO.Model? { + await fetchFirstModel(entityType: entityType, predicate: .init(gid: gid), sortDescriptors: sortDescriptors) + } + + func updateFirst( + entityType: MO.Type, + gid: String, + commitChanges: @escaping ((MO) -> Void), + sortDescriptors: [NSSortDescriptor]? = nil + ) async { + await updateFirst( + entityType: entityType, + predicate: .init(gid: gid), + commitChanges: commitChanges, + sortDescriptors: sortDescriptors + ) } } // MARK: Fetch extension DatabaseClient { - func fetchGallery(gid: String) -> Gallery? { + func fetchGallery(gid: String) async -> Gallery? { guard gid.isValidGID else { return nil } - var entity: Gallery? - AppUtil.dispatchMainSync { - entity = fetch(entityType: GalleryMO.self, gid: gid)?.toEntity() - } - return entity + return await fetchFirstModel(entityType: GalleryMO.self, gid: gid) } - func fetchGalleryDetail(gid: String) -> GalleryDetail? { + + func fetchGalleryDetail(gid: String) async -> GalleryDetail? { guard gid.isValidGID else { return nil } - var entity: GalleryDetail? - AppUtil.dispatchMainSync { - entity = fetch(entityType: GalleryDetailMO.self, gid: gid)?.toEntity() - } - return entity + return await fetchFirstModel(entityType: GalleryDetailMO.self, gid: gid) } - func fetchAppEnv() -> EffectTask { - Future { promise in - DispatchQueue.main.async { - promise(.success(fetchOrCreate(entityType: AppEnvMO.self).toEntity())) - } - } - .eraseToAnyPublisher() - .receive(on: DispatchQueue.main) - .eraseToEffect() - } - func fetchAppEnvSynchronously() -> AppEnv { - fetchOrCreate(entityType: AppEnvMO.self).toEntity() - } - func fetchGalleryState(gid: String) -> EffectTask { - guard gid.isValidGID else { return .none } - return Future { promise in - DispatchQueue.main.async { - promise(.success( - fetchOrCreate(entityType: GalleryStateMO.self, gid: gid).toEntity() - )) - } - } - .eraseToAnyPublisher() - .receive(on: DispatchQueue.main) - .eraseToEffect() - } - func fetchHistoryGalleries(fetchLimit: Int = 0) -> EffectTask<[Gallery]> { - Future { promise in - DispatchQueue.main.async { - let predicate = NSPredicate(format: "lastOpenDate != nil") - let sortDescriptor = NSSortDescriptor( - keyPath: \GalleryMO.lastOpenDate, ascending: false - ) - let galleries = batchFetch( - entityType: GalleryMO.self, fetchLimit: fetchLimit, predicate: predicate, - findBeforeFetch: false, sortDescriptors: [sortDescriptor] - ) - .map { $0.toEntity() } - promise(.success(galleries)) - } - } - .eraseToAnyPublisher() - .receive(on: DispatchQueue.main) - .eraseToEffect() + func fetchAppEnv() async -> AppEnv { + await fetchFirstModel(entityType: AppEnvMO.self) ?? .empty + } + + func fetchGalleryState(gid: String) async -> GalleryState { + guard gid.isValidGID else { return .empty(gid: gid) } + return await fetchFirstModel(entityType: GalleryStateMO.self) ?? .empty(gid: gid) + } + func fetchHistoryGalleries(fetchLimit: Int = 0) async -> [Gallery] { + let predicate = NSPredicate(format: "lastOpenDate != nil") + let sortDescriptor = NSSortDescriptor( + keyPath: \GalleryMO.lastOpenDate, ascending: false + ) + return await fetchModel( + entityType: GalleryMO.self, fetchLimit: fetchLimit, predicate: predicate, sortDescriptors: [sortDescriptor] + ) } } // MARK: FetchAccessor extension DatabaseClient { - func fetchFilterSynchronously(range: FilterRange) -> Filter { + func fetchFilter(range: FilterRange) async -> Filter { switch range { case .search: - return fetchAppEnvSynchronously().searchFilter + return await fetchAppEnv().searchFilter case .global: - return fetchAppEnvSynchronously().globalFilter + return await fetchAppEnv().globalFilter case .watched: - return fetchAppEnvSynchronously().watchedFilter + return await fetchAppEnv().watchedFilter } } - func fetchHistoryKeywords() -> EffectTask<[String]> { - fetchAppEnv().map(\.historyKeywords) + func fetchHistoryKeywords() async -> [String] { + await fetchAppEnv().historyKeywords } - func fetchQuickSearchWords() -> EffectTask<[QuickSearchWord]> { - fetchAppEnv().map(\.quickSearchWords) + func fetchQuickSearchWords() async -> [QuickSearchWord] { + await fetchAppEnv().quickSearchWords } - func fetchGalleryPreviewURLs(gid: String) -> EffectTask<[Int: URL]> { - guard gid.isValidGID else { return .none } - return fetchGalleryState(gid: gid).map(\.previewURLs) + func fetchGalleryPreviewURLs(gid: String) async -> [Int: URL] { + guard gid.isValidGID else { return .init() } + return await fetchGalleryState(gid: gid).previewURLs } } -// MARK: UpdateGallery +// MARK: CacheGallery extension DatabaseClient { - func updateGallery(gid: String, key: String, value: Any?) -> EffectTask { - guard gid.isValidGID else { return .none } - return .fireAndForget { - DispatchQueue.main.async { - update( - entityType: GalleryMO.self, gid: gid, createIfNil: true, - commitChanges: { $0.setValue(value, forKeyPath: key) } - ) - } - } + func updateGallery(gid: String, key: String, value: Any?) async { + guard gid.isValidGID else { return } + await updateFirst( + entityType: GalleryMO.self, gid: gid, + commitChanges: { $0.setValue(value, forKeyPath: key) } + ) } - func updateLastOpenDate(gid: String, date: Date = .now) -> EffectTask { - guard gid.isValidGID else { return .none } - return updateGallery(gid: gid, key: "lastOpenDate", value: date) - } - func clearHistoryGalleries() -> EffectTask { - .fireAndForget { - DispatchQueue.main.async { - let predicate = NSPredicate(format: "lastOpenDate != nil") - batchUpdate(entityType: GalleryMO.self, predicate: predicate) { galleryMOs in - galleryMOs.forEach { galleryMO in - galleryMO.lastOpenDate = nil - } - } + func updateLastOpenDate(gid: String, date: Date = .now) async { + guard gid.isValidGID else { return } + await updateGallery(gid: gid, key: "lastOpenDate", value: date) + } + func clearHistoryGalleries() async { + let predicate = NSPredicate(format: "lastOpenDate != nil") + await update(entityType: GalleryMO.self, predicate: predicate) { galleryMOs, _ in + galleryMOs.forEach { galleryMO in + galleryMO.lastOpenDate = nil } } } - func cacheGalleries(_ galleries: [Gallery]) -> EffectTask { - .fireAndForget { - DispatchQueue.main.async { - for gallery in galleries.filter({ $0.id.isValidGID }) { - let storedMO = fetch( - entityType: GalleryMO.self, gid: gallery.gid - ) { managedObject in - managedObject?.category = gallery.category.rawValue - managedObject?.coverURL = gallery.coverURL - managedObject?.galleryURL = gallery.galleryURL - // managedObject?.lastOpenDate = gallery.lastOpenDate - managedObject?.pageCount = Int64(gallery.pageCount) - managedObject?.postedDate = gallery.postedDate - managedObject?.rating = gallery.rating - managedObject?.tags = gallery.tags.toData() - managedObject?.title = gallery.title - managedObject?.token = gallery.token - if let uploader = gallery.uploader { - managedObject?.uploader = uploader + func cacheGalleries(_ galleries: [Gallery]) async { + await withTaskGroup(of: Void.self) { group in + for gallery in galleries.filter({ $0.id.isValidGID }) { + group.addTask { + if await fetchGallery(gid: gallery.gid) != nil { + await updateFirst( + entityType: GalleryMO.self, gid: gallery.gid + ) { managedObject in + managedObject.category = gallery.category.rawValue + managedObject.coverURL = gallery.coverURL + managedObject.galleryURL = gallery.galleryURL + // managedObject.lastOpenDate = gallery.lastOpenDate + managedObject.pageCount = Int64(gallery.pageCount) + managedObject.postedDate = gallery.postedDate + managedObject.rating = gallery.rating + managedObject.tags = gallery.tags.toData() + managedObject.title = gallery.title + managedObject.token = gallery.token + if let uploader = gallery.uploader { + managedObject.uploader = uploader + } } - } - if storedMO == nil { - gallery.toManagedObject(in: PersistenceController.shared.container.viewContext) + } else { + await create(model: gallery) } } - saveContext() } } } } -// MARK: UpdateGalleryDetail +// MARK: CacheGalleryDetail extension DatabaseClient { - func cacheGalleryDetail(_ detail: GalleryDetail) -> EffectTask { - guard detail.gid.isValidGID else { return .none } - return .fireAndForget { - DispatchQueue.main.async { - let storedMO = fetch( - entityType: GalleryDetailMO.self, gid: detail.gid - ) { managedObject in - managedObject?.archiveURL = detail.archiveURL - managedObject?.category = detail.category.rawValue - managedObject?.coverURL = detail.coverURL - managedObject?.isFavorited = detail.isFavorited - managedObject?.visibility = detail.visibility.toData() - managedObject?.jpnTitle = detail.jpnTitle - managedObject?.language = detail.language.rawValue - managedObject?.favoritedCount = Int64(detail.favoritedCount) - managedObject?.pageCount = Int64(detail.pageCount) - managedObject?.parentURL = detail.parentURL - managedObject?.postedDate = detail.postedDate - managedObject?.rating = detail.rating - managedObject?.userRating = detail.userRating - managedObject?.ratingCount = Int64(detail.ratingCount) - managedObject?.sizeCount = detail.sizeCount - managedObject?.sizeType = detail.sizeType - managedObject?.title = detail.title - managedObject?.torrentCount = Int64(detail.torrentCount) - managedObject?.uploader = detail.uploader - } - if storedMO == nil { - detail.toManagedObject(in: PersistenceController.shared.container.viewContext) - } - saveContext() + func cacheGalleryDetail(_ detail: GalleryDetail) async { + guard detail.gid.isValidGID else { return } + + if await fetchFirstModel(entityType: GalleryDetailMO.self, gid: detail.gid) != nil { + await updateFirst( + entityType: GalleryDetailMO.self, gid: detail.gid + ) { managedObject in + managedObject.archiveURL = detail.archiveURL + managedObject.category = detail.category.rawValue + managedObject.coverURL = detail.coverURL + managedObject.isFavorited = detail.isFavorited + managedObject.visibility = detail.visibility.toData() + managedObject.jpnTitle = detail.jpnTitle + managedObject.language = detail.language.rawValue + managedObject.favoritedCount = .init(detail.favoritedCount) + managedObject.pageCount = .init(detail.pageCount) + managedObject.parentURL = detail.parentURL + managedObject.postedDate = detail.postedDate + managedObject.rating = detail.rating + managedObject.userRating = detail.userRating + managedObject.ratingCount = .init(detail.ratingCount) + managedObject.sizeCount = detail.sizeCount + managedObject.sizeType = detail.sizeType + managedObject.title = detail.title + managedObject.torrentCount = .init(detail.torrentCount) + managedObject.uploader = detail.uploader } + } else { + await create(model: detail) } } } -// MARK: UpdateGalleryState +// MARK: CacheGalleryState extension DatabaseClient { - func updateGalleryState(gid: String, commitChanges: @escaping (GalleryStateMO) -> Void) -> EffectTask { - guard gid.isValidGID else { return .none } - return .fireAndForget { - DispatchQueue.main.async { - update( - entityType: GalleryStateMO.self, gid: gid, createIfNil: true, - commitChanges: commitChanges - ) - } + func updateGalleryState(gid: String, commitChanges: @escaping (GalleryStateMO) -> Void) async { + guard gid.isValidGID else { return } + await updateFirst(entityType: GalleryStateMO.self, gid: gid, commitChanges: commitChanges) + } + + func cacheGalleryState(gid: String, commitChanges: @escaping (GalleryStateMO) -> Void) async { + guard gid.isValidGID else { return } + if await fetchFirstModel(entityType: GalleryStateMO.self, gid: gid) != nil { + await updateFirst(entityType: GalleryStateMO.self, gid: gid, commitChanges: commitChanges) + } else { + await create(entityType: GalleryStateMO.self, commitChanges: { mo, _ in commitChanges(mo) }) } } - func updateGalleryState(gid: String, key: String, value: Any?) -> EffectTask { - guard gid.isValidGID else { return .none } - return updateGalleryState(gid: gid) { stateMO in + + func cacheGalleryState(gid: String, key: String, value: Any?) async { + guard gid.isValidGID else { return } + await cacheGalleryState(gid: gid) { stateMO in stateMO.setValue(value, forKeyPath: key) } } - 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) -> EffectTask { - guard gid.isValidGID else { return .none } - return updateGalleryState(gid: gid, key: "previewConfig", value: config.toData()) - } - 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]) -> EffectTask { - guard gid.isValidGID else { return .none } - return updateGalleryState(gid: gid, key: "comments", value: comments.toData()) - } - func removeImageURLs(gid: String) -> EffectTask { - guard gid.isValidGID else { return .none } - return updateGalleryState(gid: gid) { galleryStateMO in - galleryStateMO.imageURLs = nil - galleryStateMO.previewURLs = nil - galleryStateMO.thumbnailURLs = nil - galleryStateMO.originalImageURLs = nil - } + func cacheGalleryTags(gid: String, tags: [GalleryTag]) async { + guard gid.isValidGID else { return } + await cacheGalleryState(gid: gid, key: "tags", value: tags.toData()) } - func removeImageURLs() -> EffectTask { - .fireAndForget { - DispatchQueue.main.async { - batchUpdate(entityType: GalleryStateMO.self) { galleryStateMOs in - galleryStateMOs.forEach { galleryStateMO in - galleryStateMO.imageURLs = nil - galleryStateMO.previewURLs = nil - galleryStateMO.thumbnailURLs = nil - galleryStateMO.originalImageURLs = nil - } - } - } - } + func cachePreviewConfig(gid: String, config: PreviewConfig) async { + guard gid.isValidGID else { return } + await cacheGalleryState(gid: gid, key: "previewConfig", value: config.toData()) } - func removeExpiredImageURLs() -> EffectTask { - fetchHistoryGalleries() - .map { $0.filter { Date().timeIntervalSince($0.lastOpenDate ?? .distantPast) > .oneWeek } } - .map { $0.map { removeImageURLs(gid: $0.id) } } - .map(EffectTask.merge) - .fireAndForget() + func cacheReadingProgress(gid: String, progress: Int) async { + guard gid.isValidGID else { return } + await cacheGalleryState(gid: gid, key: "readingProgress", value: Int64(progress)) } - func updateThumbnailURLs(gid: String, thumbnailURLs: [Int: URL]) -> EffectTask { - guard gid.isValidGID else { return .none } - return updateGalleryState(gid: gid) { galleryStateMO in + func cacheComments(gid: String, comments: [GalleryComment]) async { + guard gid.isValidGID else { return } + await cacheGalleryState(gid: gid, key: "comments", value: comments.toData()) + } + func cacheThumbnailURLs(gid: String, thumbnailURLs: [Int: URL]) async { + guard gid.isValidGID else { return } + await cacheGalleryState(gid: gid) { galleryStateMO in update(gid: gid, storedData: &galleryStateMO.thumbnailURLs, new: thumbnailURLs) } } - func updateImageURLs( + func cacheImageURLs( gid: String, imageURLs: [Int: URL], originalImageURLs: [Int: URL] - ) -> EffectTask { - guard gid.isValidGID else { return .none } - return updateGalleryState(gid: gid) { galleryStateMO in + ) async { + guard gid.isValidGID else { return } + await cacheGalleryState(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]) -> EffectTask { - guard gid.isValidGID else { return .none } - return updateGalleryState(gid: gid) { galleryStateMO in + func cachePreviewURLs(gid: String, previewURLs: [Int: URL]) async { + guard gid.isValidGID else { return } + await cacheGalleryState(gid: gid) { galleryStateMO in update(gid: gid, storedData: &galleryStateMO.previewURLs, new: previewURLs) } } @@ -485,24 +443,56 @@ extension DatabaseClient { storedData = new.toData() } } + + func removeImageURLs(gid: String) async { + guard gid.isValidGID else { return } + await updateGalleryState(gid: gid) { galleryStateMO in + galleryStateMO.imageURLs = nil + galleryStateMO.previewURLs = nil + galleryStateMO.thumbnailURLs = nil + galleryStateMO.originalImageURLs = nil + } + } + func removeImageURLs() async { + await update(entityType: GalleryStateMO.self) { galleryStateMOs, _ in + galleryStateMOs.forEach { galleryStateMO in + galleryStateMO.imageURLs = nil + galleryStateMO.previewURLs = nil + galleryStateMO.thumbnailURLs = nil + galleryStateMO.originalImageURLs = nil + } + } + } + func removeExpiredImageURLs() async { + let galleries = await fetchHistoryGalleries() + .filter({ Date().timeIntervalSince($0.lastOpenDate ?? .distantPast) > .oneWeek }) + + await withTaskGroup(of: Void.self) { group in + galleries.forEach { gallery in + group.addTask { + await removeImageURLs(gid: gallery.id) } + } + } + } } -// MARK: UpdateAppEnv +// MARK: CacheAppEnv extension DatabaseClient { - func updateAppEnv(key: String, value: Any?) -> EffectTask { - .fireAndForget { - DispatchQueue.main.async { - update( - entityType: AppEnvMO.self, createIfNil: true, - commitChanges: { $0.setValue(value, forKeyPath: key) } - ) - } + func cacheAppEnv(key: String, value: Any?) async { + if await fetchFirstModel(entityType: AppEnvMO.self) != nil { + await updateFirst( + entityType: AppEnvMO.self, + commitChanges: { mo in mo.setValue(value, forKeyPath: key) } + ) + } else { + await create(entityType: AppEnvMO.self) } } - func updateSetting(_ setting: Setting) -> EffectTask { - updateAppEnv(key: "setting", value: setting.toData()) + + func cacheSetting(_ setting: Setting) async { + await cacheAppEnv(key: "setting", value: setting.toData()) } - func updateFilter(_ filter: Filter, range: FilterRange) -> EffectTask { + func cacheFilter(_ filter: Filter, range: FilterRange) async { let key: String switch range { case .search: @@ -512,39 +502,34 @@ extension DatabaseClient { case .watched: key = "watchedFilter" } - return updateAppEnv(key: key, value: filter.toData()) + await cacheAppEnv(key: key, value: filter.toData()) } - func updateTagTranslator(_ tagTranslator: TagTranslator) -> EffectTask { - updateAppEnv(key: "tagTranslator", value: tagTranslator.toData()) + func cacheTagTranslator(_ tagTranslator: TagTranslator) async { + await cacheAppEnv(key: "tagTranslator", value: tagTranslator.toData()) } - func updateUser(_ user: User) -> EffectTask { - updateAppEnv(key: "user", value: user.toData()) + func cacheUser(_ user: User) async { + await cacheAppEnv(key: "user", value: user.toData()) } - func updateHistoryKeywords(_ keywords: [String]) -> EffectTask { - updateAppEnv(key: "historyKeywords", value: keywords.toData()) + func cacheHistoryKeywords(_ keywords: [String]) async { + await cacheAppEnv(key: "historyKeywords", value: keywords.toData()) } - func updateQuickSearchWords(_ words: [QuickSearchWord]) -> EffectTask { - updateAppEnv(key: "quickSearchWords", value: words.toData()) + func cacheQuickSearchWords(_ words: [QuickSearchWord]) async { + await cacheAppEnv(key: "quickSearchWords", value: words.toData()) } // Update User - func updateUserProperty(_ commitChanges: @escaping (inout User) -> Void) -> EffectTask { - fetchAppEnv().map(\.user) - .map { (user: User) -> User in - var user = user - commitChanges(&user) - return user - } - .flatMap(updateUser) - .eraseToEffect() + func cacheUserProperty(_ commitChanges: @escaping (inout User) -> Void) async { + var user = await fetchAppEnv().user + commitChanges(&user) + await cacheUser(user) } - func updateGreeting(_ greeting: Greeting) -> EffectTask { - updateUserProperty { user in + func cacheGreeting(_ greeting: Greeting) async { + await cacheUserProperty { user in user.greeting = greeting } } - func updateGalleryFunds(galleryPoints: String, credits: String) -> EffectTask { - updateUserProperty { user in + func cacheGalleryFunds(galleryPoints: String, credits: String) async { + await cacheUserProperty { user in user.credits = credits user.galleryPoints = galleryPoints } diff --git a/EhPanda/App/Tools/Clients/FileClient.swift b/EhPanda/App/Tools/Clients/FileClient.swift index 15461dd9..b27072eb 100644 --- a/EhPanda/App/Tools/Clients/FileClient.swift +++ b/EhPanda/App/Tools/Clients/FileClient.swift @@ -11,9 +11,9 @@ import ComposableArchitecture struct FileClient { let createFile: (String, Data?) -> Bool - let fetchLogs: () -> EffectTask> - let deleteLog: (String) -> EffectTask> - let importTagTranslator: (URL) -> EffectTask> + let fetchLogs: () async -> Result<[Log], AppError> + let deleteLog: (String) async -> Result + let importTagTranslator: (URL) async -> Result } extension FileClient { @@ -22,76 +22,61 @@ extension FileClient { FileManager.default.createFile(atPath: path, contents: data, attributes: nil) }, fetchLogs: { - Future { promise in - DispatchQueue.global(qos: .userInitiated).async { - guard let path = FileUtil.logsDirectoryURL?.path, - let enumerator = FileManager.default.enumerator(atPath: path), - let fileNames = (enumerator.allObjects as? [String])? - .filter({ $0.contains(Defaults.FilePath.ehpandaLog) }) - else { - promise(.failure(.notFound)) - return - } + await withCheckedContinuation { continuation in + guard let path = FileUtil.logsDirectoryURL?.path, + let enumerator = FileManager.default.enumerator(atPath: path), + let filenames = (enumerator.allObjects as? [String])? + .filter({ $0.contains(Defaults.FilePath.ehpandaLog) }) + else { + continuation.resume(returning: .failure(.notFound)) + return + } - let logs: [Log] = fileNames.compactMap { name in - guard let fileURL = FileUtil.logsDirectoryURL?.appendingPathComponent(name), - let content = try? String(contentsOf: fileURL) - else { return nil } + let logs: [Log] = filenames.compactMap { name in + guard let fileURL = FileUtil.logsDirectoryURL?.appendingPathComponent(name), + let content = try? String(contentsOf: fileURL) + else { return nil } - return Log( - fileName: name, contents: content - .components(separatedBy: "\n") - .filter({ !$0.isEmpty }) - ) - } - .sorted() - promise(.success(logs)) + return Log( + fileName: name, contents: content + .components(separatedBy: "\n") + .filter({ !$0.isEmpty }) + ) } + .sorted() + + continuation.resume(returning: .success(logs)) } - .eraseToAnyPublisher() - .receive(on: DispatchQueue.main) - .catchToEffect() }, - deleteLog: { fileName in - Future { promise in - guard let fileURL = FileUtil.logsDirectoryURL?.appendingPathComponent(fileName) + deleteLog: { filename in + await withCheckedContinuation { continuation in + guard let fileURL = FileUtil.logsDirectoryURL?.appendingPathComponent(filename) else { - promise(.failure(.notFound)) + continuation.resume(returning: .failure(.notFound)) return } try? FileManager.default.removeItem(at: fileURL) if FileManager.default.fileExists(atPath: fileURL.path) { - promise(.failure(.unknown)) + continuation.resume(returning: .failure(.unknown)) } - promise(.success(fileName)) + continuation.resume(returning: .success(filename)) } - .eraseToAnyPublisher() - .receive(on: DispatchQueue.main) - .catchToEffect() }, importTagTranslator: { url in - Future { promise in - DispatchQueue.global(qos: .userInitiated).async { - guard let data = try? Data(contentsOf: url), - let translations = try? JSONDecoder().decode( - EhTagTranslationDatabaseResponse.self, from: data - ).tagTranslations - else { - promise(.failure(.parseFailed)) - return - } - guard !translations.isEmpty else { - promise(.failure(.parseFailed)) - return - } - promise(.success(.init(hasCustomTranslations: true, translations: translations))) + await withCheckedContinuation { continuation in + guard let data = try? Data(contentsOf: url), + let translations = try? JSONDecoder().decode(EhTagTranslationDatabaseResponse.self, from: data) + .tagTranslations, + !translations.isEmpty + else { + continuation.resume(returning: .failure(.parseFailed)) + return } + + continuation.resume(returning: .success(.init(hasCustomTranslations: true, translations: translations))) } - .eraseToAnyPublisher() - .receive(on: DispatchQueue.main) - .catchToEffect() } ) @@ -123,9 +108,9 @@ extension DependencyValues { extension FileClient { static let noop: Self = .init( createFile: { _, _ in false }, - fetchLogs: { .none }, - deleteLog: { _ in .none }, - importTagTranslator: { _ in .none } + fetchLogs: { .failure(.notFound) }, + deleteLog: { _ in .failure(.notFound) }, + importTagTranslator: { _ in .failure(.notFound) } ) static let unimplemented: Self = .init( diff --git a/EhPanda/App/Tools/Clients/ImageClient.swift b/EhPanda/App/Tools/Clients/ImageClient.swift index f31a8638..0875a2ff 100644 --- a/EhPanda/App/Tools/Clients/ImageClient.swift +++ b/EhPanda/App/Tools/Clients/ImageClient.swift @@ -12,77 +12,70 @@ import Kingfisher import ComposableArchitecture struct ImageClient { - let prefetchImages: ([URL]) -> EffectTask - let saveImageToPhotoLibrary: (UIImage, Bool) -> EffectTask - let downloadImage: (URL) -> EffectTask> - let retrieveImage: (String) -> EffectTask> + let prefetchImages: ([URL]) -> Void + let saveImageToPhotoLibrary: (UIImage, Bool) async -> Bool + let downloadImage: (URL) async -> Result + let retrieveImage: (String) async -> Result } extension ImageClient { static let live: Self = .init( prefetchImages: { urls in - .fireAndForget { - ImagePrefetcher(urls: urls).start() - } + ImagePrefetcher(urls: urls).start() }, saveImageToPhotoLibrary: { (image, isAnimated) in - Future { promise in - DispatchQueue.global(qos: .utility).async { - if let data = image.kf.data(format: isAnimated ? .GIF : .unknown) { - PHPhotoLibrary.shared().performChanges { - let request = PHAssetCreationRequest.forAsset() - request.addResource(with: .photo, data: data, options: nil) - } completionHandler: { (isSuccess, _) in - promise(.success(isSuccess)) - } - } else { - promise(.success(false)) + if let data = await withCheckedContinuation({ continuation in + continuation.resume(returning: image.kf.data(format: isAnimated ? .GIF : .unknown)) + }) { + do { + try await PHPhotoLibrary.shared().performChanges { + let request = PHAssetCreationRequest.forAsset() + request.addResource(with: .photo, data: data, options: nil) } + return true + } catch { + return false } + } else { + return false } - .eraseToAnyPublisher() - .receive(on: DispatchQueue.main) - .eraseToEffect() }, downloadImage: { url in - Future { promise in + await withCheckedContinuation { continuation in KingfisherManager.shared.downloader.downloadImage(with: url, options: nil) { result in - switch result { - case .success(let result): - promise(.success(result.image)) + switch result.map(\.image) { + case .success(let image): + continuation.resume(returning: .success(image)) + case .failure(let error): - promise(.failure(error)) + continuation.resume(returning: .failure(error)) } } } - .eraseToAnyPublisher() - .catchToEffect() }, retrieveImage: { key in - Future { promise in + await withCheckedContinuation { continuation in KingfisherManager.shared.cache.retrieveImage(forKey: key) { result in switch result { case .success(let result): if let image = result.image { - promise(.success(image)) + continuation.resume(returning: .success(image)) } else { - promise(.failure(AppError.notFound)) + continuation.resume(returning: .failure(AppError.notFound)) } case .failure(let error): - promise(.failure(error)) + continuation.resume(returning: .failure(error)) } } } - .eraseToAnyPublisher() - .catchToEffect() } ) - func fetchImage(url: URL) -> EffectTask> { + func fetchImage(url: URL) async -> Result { if KingfisherManager.shared.cache.isCached(forKey: url.absoluteString) { - return retrieveImage(url.absoluteString) + return await retrieveImage(url.absoluteString) } else { - return downloadImage(url) + return await downloadImage(url) } } } @@ -121,10 +114,10 @@ extension DependencyValues { // MARK: Test extension ImageClient { static let noop: Self = .init( - prefetchImages: { _ in .none }, - saveImageToPhotoLibrary: { _, _ in .none }, - downloadImage: { _ in .none }, - retrieveImage: { _ in .none } + prefetchImages: { _ in }, + saveImageToPhotoLibrary: { _, _ in false }, + downloadImage: { _ in .failure(AppError.unknown) }, + retrieveImage: { _ in .failure(AppError.unknown) } ) static let unimplemented: Self = .init( diff --git a/EhPanda/App/Tools/Clients/LibraryClient.swift b/EhPanda/App/Tools/Clients/LibraryClient.swift index 5f566e54..393a7d10 100644 --- a/EhPanda/App/Tools/Clients/LibraryClient.swift +++ b/EhPanda/App/Tools/Clients/LibraryClient.swift @@ -14,76 +14,65 @@ import UIImageColors import ComposableArchitecture struct LibraryClient { - let initializeLogger: () -> EffectTask - let initializeWebImage: () -> EffectTask - let clearWebImageDiskCache: () -> EffectTask - let analyzeImageColors: (UIImage) -> EffectTask - let calculateWebImageDiskCacheSize: () -> EffectTask + let initializeLogger: () -> Void + let initializeWebImage: () -> Void + let clearWebImageDiskCache: () -> Void + let analyzeImageColors: (UIImage) async -> UIImageColors? + let calculateWebImageDiskCacheSize: () async -> UInt? } extension LibraryClient { static let live: Self = .init( initializeLogger: { - .fireAndForget { - // MARK: SwiftyBeaver - let file = FileDestination() - let console = ConsoleDestination() - let format = [ - "$Dyyyy-MM-dd HH:mm:ss.SSS$d", - "$C$L$c $N.$F:$l - $M $X" - ].joined(separator: " ") + // MARK: SwiftyBeaver + let file = FileDestination() + let console = ConsoleDestination() + let format = [ + "$Dyyyy-MM-dd HH:mm:ss.SSS$d", + "$C$L$c $N.$F:$l - $M $X" + ].joined(separator: " ") - file.format = format - file.logFileAmount = 10 - file.calendar = Calendar(identifier: .gregorian) - file.logFileURL = FileUtil.logsDirectoryURL? - .appendingPathComponent(Defaults.FilePath.ehpandaLog) + file.format = format + file.logFileAmount = 10 + file.calendar = Calendar(identifier: .gregorian) + file.logFileURL = FileUtil.logsDirectoryURL? + .appendingPathComponent(Defaults.FilePath.ehpandaLog) - console.format = format - console.calendar = Calendar(identifier: .gregorian) - console.asynchronously = false - console.levelColor.verbose = "😪" - console.levelColor.warning = "⚠️" - console.levelColor.error = "‼️" - console.levelColor.debug = "🐛" - console.levelColor.info = "📖" + console.format = format + console.calendar = Calendar(identifier: .gregorian) + console.asynchronously = false + console.levelColor.verbose = "😪" + console.levelColor.warning = "⚠️" + console.levelColor.error = "‼️" + console.levelColor.debug = "🐛" + console.levelColor.info = "📖" - SwiftyBeaver.addDestination(file) - #if DEBUG - SwiftyBeaver.addDestination(console) - #endif - } + SwiftyBeaver.addDestination(file) + #if DEBUG + SwiftyBeaver.addDestination(console) + #endif }, initializeWebImage: { - .fireAndForget { - let config = KingfisherManager.shared.downloader.sessionConfiguration - config.httpCookieStorage = HTTPCookieStorage.shared - KingfisherManager.shared.downloader.sessionConfiguration = config - } + let config = KingfisherManager.shared.downloader.sessionConfiguration + config.httpCookieStorage = HTTPCookieStorage.shared + KingfisherManager.shared.downloader.sessionConfiguration = config }, clearWebImageDiskCache: { - .fireAndForget { - KingfisherManager.shared.cache.clearDiskCache() - } + KingfisherManager.shared.cache.clearDiskCache() }, analyzeImageColors: { image in - Future { promise in + await withCheckedContinuation { continuation in image.getColors(quality: .lowest) { colors in - promise(.success(colors)) + continuation.resume(returning: colors) } } - .eraseToAnyPublisher() - .eraseToEffect() }, calculateWebImageDiskCacheSize: { - Future { promise in + await withCheckedContinuation { continuation in KingfisherManager.shared.cache.calculateDiskStorageSize { - promise(.success(try? $0.get())) + continuation.resume(returning: try? $0.get()) } } - .eraseToAnyPublisher() - .receive(on: DispatchQueue.main) - .eraseToEffect() } ) } @@ -105,11 +94,11 @@ extension DependencyValues { // MARK: Test extension LibraryClient { static let noop: Self = .init( - initializeLogger: { .none }, - initializeWebImage: { .none }, - clearWebImageDiskCache: { .none }, - analyzeImageColors: { _ in .none }, - calculateWebImageDiskCacheSize: { .none } + initializeLogger: {}, + initializeWebImage: {}, + clearWebImageDiskCache: {}, + analyzeImageColors: { _ in nil }, + calculateWebImageDiskCacheSize: { nil } ) static let unimplemented: Self = .init( diff --git a/EhPanda/App/Tools/Clients/LoggerClient.swift b/EhPanda/App/Tools/Clients/LoggerClient.swift index ebeefb83..b1bfc6ad 100644 --- a/EhPanda/App/Tools/Clients/LoggerClient.swift +++ b/EhPanda/App/Tools/Clients/LoggerClient.swift @@ -8,21 +8,17 @@ import ComposableArchitecture struct LoggerClient { - let info: (Any, Any?) -> EffectTask - let error: (Any, Any?) -> EffectTask + let info: (Any, Any?) -> Void + let error: (Any, Any?) -> Void } extension LoggerClient { static let live: Self = .init( info: { message, context in - .fireAndForget { - Logger.info(message, context: context) - } + Logger.info(message, context: context) }, error: { message, context in - .fireAndForget { - Logger.error(message, context: context) - } + Logger.error(message, context: context) } ) } @@ -44,8 +40,8 @@ extension DependencyValues { // MARK: Test extension LoggerClient { static let noop: Self = .init( - info: { _, _ in .none }, - error: { _, _ in .none } + info: { _, _ in }, + error: { _, _ in } ) static let unimplemented: Self = .init( diff --git a/EhPanda/App/Tools/Clients/UIApplicationClient.swift b/EhPanda/App/Tools/Clients/UIApplicationClient.swift index 97aef088..56e201df 100644 --- a/EhPanda/App/Tools/Clients/UIApplicationClient.swift +++ b/EhPanda/App/Tools/Clients/UIApplicationClient.swift @@ -10,60 +10,45 @@ import Combine import ComposableArchitecture struct UIApplicationClient { - let openURL: (URL) -> EffectTask - let hideKeyboard: () -> EffectTask + let openURL: (URL) -> Void + let hideKeyboard: () -> Void let alternateIconName: () -> String? - let setAlternateIconName: (String?) -> EffectTask> - let setUserInterfaceStyle: (UIUserInterfaceStyle) -> EffectTask + let setAlternateIconName: (String?) async -> Bool + let setUserInterfaceStyle: (UIUserInterfaceStyle) -> Void } extension UIApplicationClient { static let live: Self = .init( openURL: { url in - .fireAndForget { - UIApplication.shared.open(url, options: [:]) - } + UIApplication.shared.open(url, options: [:]) }, hideKeyboard: { - .fireAndForget { - UIApplication.shared.endEditing() - } + UIApplication.shared.endEditing() }, alternateIconName: { UIApplication.shared.alternateIconName }, setAlternateIconName: { iconName in - Future { promise in - UIApplication.shared.setAlternateIconName(iconName) { error in - if let error = error { - promise(.success(false)) - } else { - promise(.success(true)) - } - } + do { + try await UIApplication.shared.setAlternateIconName(iconName) + return true + } catch { + return false } - .eraseToAnyPublisher() - .catchToEffect() }, setUserInterfaceStyle: { userInterfaceStyle in - .fireAndForget { - (DeviceUtil.keyWindow ?? DeviceUtil.anyWindow)?.overrideUserInterfaceStyle = userInterfaceStyle - } + (DeviceUtil.keyWindow ?? DeviceUtil.anyWindow)?.overrideUserInterfaceStyle = userInterfaceStyle } ) - func openSettings() -> EffectTask { - if let url = URL(string: UIApplication.openSettingsURLString) { - return openURL(url) - } - return .none + func openSettings() { + URL(string: UIApplication.openSettingsURLString).map(openURL) } - func openFileApp() -> EffectTask { + func openFileApp() { if let dirPath = FileUtil.logsDirectoryURL?.path, let dirURL = URL(string: "shareddocuments://" + dirPath) { - return openURL(dirURL) + openURL(dirURL) } - return .none } } @@ -84,11 +69,11 @@ extension DependencyValues { // MARK: Test extension UIApplicationClient { static let noop: Self = .init( - openURL: { _ in .none}, - hideKeyboard: { .none }, + openURL: { _ in }, + hideKeyboard: {}, alternateIconName: { nil }, - setAlternateIconName: { _ in .none }, - setUserInterfaceStyle: { _ in .none } + setAlternateIconName: { _ in false }, + setUserInterfaceStyle: { _ in } ) static let unimplemented: Self = .init( diff --git a/EhPanda/App/Tools/Clients/UserDefaultsClient.swift b/EhPanda/App/Tools/Clients/UserDefaultsClient.swift index c403f278..4ae104be 100644 --- a/EhPanda/App/Tools/Clients/UserDefaultsClient.swift +++ b/EhPanda/App/Tools/Clients/UserDefaultsClient.swift @@ -9,15 +9,13 @@ import Foundation import ComposableArchitecture struct UserDefaultsClient { - let setValue: (Any, AppUserDefaults) -> EffectTask + let setValue: (Any, AppUserDefaults) -> Void } extension UserDefaultsClient { static let live: Self = .init( setValue: { value, key in - .fireAndForget { - UserDefaults.standard.set(value, forKey: key.rawValue) - } + UserDefaults.standard.set(value, forKey: key.rawValue) } ) @@ -43,7 +41,7 @@ extension DependencyValues { // MARK: Test extension UserDefaultsClient { static let noop: Self = .init( - setValue: { _, _ in .none } + setValue: { _, _ in } ) static let unimplemented: Self = .init( diff --git a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift b/EhPanda/App/Tools/Extensions/AlertKit+.swift similarity index 98% rename from EhPanda/App/Tools/Extensions/AlertKit_Extension.swift rename to EhPanda/App/Tools/Extensions/AlertKit+.swift index 055a3cc5..c743cf7f 100644 --- a/EhPanda/App/Tools/Extensions/AlertKit_Extension.swift +++ b/EhPanda/App/Tools/Extensions/AlertKit+.swift @@ -1,5 +1,5 @@ // -// AlertKit_Extension.swift +// AlertKit+.swift // EhPanda // // Created by 荒木辰造 on R 4/01/08. diff --git a/EhPanda/App/Tools/Extensions/CoreData+.swift b/EhPanda/App/Tools/Extensions/CoreData+.swift new file mode 100644 index 00000000..ef2354d1 --- /dev/null +++ b/EhPanda/App/Tools/Extensions/CoreData+.swift @@ -0,0 +1,25 @@ +// +// CoreData+.swift +// EhPanda +// +// Created by Chihchy on 2024/01/28. +// + +import CoreData + +extension NSManagedObjectContext { + func saveIfNeeded() { + let task = { [weak self] in + guard let self = self else { return } + if self.hasChanges { + do { + try self.save() + } catch { + Logger.error(error) + assertionFailure("Unresolved error \(error)") + } + } + } + performAndWait(task) + } +} diff --git a/EhPanda/App/Tools/Extensions/NSPredicate+.swift b/EhPanda/App/Tools/Extensions/NSPredicate+.swift new file mode 100644 index 00000000..83f20631 --- /dev/null +++ b/EhPanda/App/Tools/Extensions/NSPredicate+.swift @@ -0,0 +1,14 @@ +// +// NSPredicate+.swift +// EhPanda +// +// Created by Chihchy on 2024/01/28. +// + +import Foundation + +extension NSPredicate { + convenience init(gid: String) { + self.init(format: "gid == %@", gid) + } +} diff --git a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift b/EhPanda/App/Tools/Extensions/Reducer+.swift similarity index 97% rename from EhPanda/App/Tools/Extensions/Reducer_Extension.swift rename to EhPanda/App/Tools/Extensions/Reducer+.swift index 1fe1c4ae..6c2f2593 100644 --- a/EhPanda/App/Tools/Extensions/Reducer_Extension.swift +++ b/EhPanda/App/Tools/Extensions/Reducer+.swift @@ -1,5 +1,5 @@ // -// Reducer_Extension.swift +// Reducer+.swift // EhPanda // // Created by 荒木辰造 on R 4/02/02. @@ -23,7 +23,7 @@ extension ReducerProtocol { private func onBecomeNonNil( unwrapping enum: @escaping (State) -> Enum?, case casePath: CasePath, - perform additionalEffects: @escaping (inout State, Action) -> EffectTask + perform additionalEffects: @escaping (inout State, Action) -> Effect ) -> some ReducerProtocol { Reduce { state, action in let previousCase = Binding.constant(`enum`(state)).case(casePath).wrappedValue diff --git a/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift b/EhPanda/App/Tools/Extensions/SwiftUINavigation+.swift similarity index 98% rename from EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift rename to EhPanda/App/Tools/Extensions/SwiftUINavigation+.swift index 45c35763..8324bc92 100644 --- a/EhPanda/App/Tools/Extensions/SwiftUINavigation_Extension.swift +++ b/EhPanda/App/Tools/Extensions/SwiftUINavigation+.swift @@ -1,5 +1,5 @@ // -// SwiftUINavigation_Extension.swift +// SwiftUINavigation+.swift // EhPanda // // Created by 荒木辰造 on R 4/01/13. diff --git a/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift b/EhPanda/App/Tools/Extensions/TTProgressHUD+.swift similarity index 97% rename from EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift rename to EhPanda/App/Tools/Extensions/TTProgressHUD+.swift index b9948d23..48d51492 100644 --- a/EhPanda/App/Tools/Extensions/TTProgressHUD_Extension.swift +++ b/EhPanda/App/Tools/Extensions/TTProgressHUD+.swift @@ -1,5 +1,5 @@ // -// TTProgressHUD_Extension.swift +// TTProgressHUD+.swift // EhPanda // // Created by 荒木辰造 on R 4/01/15. diff --git a/EhPanda/DataFlow/AppDelegateReducer.swift b/EhPanda/DataFlow/AppDelegateReducer.swift index 53efa716..66bf68be 100644 --- a/EhPanda/DataFlow/AppDelegateReducer.swift +++ b/EhPanda/DataFlow/AppDelegateReducer.swift @@ -29,18 +29,18 @@ struct AppDelegateReducer: 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)) - ) + return .run { send in + libraryClient.initializeLogger() + libraryClient.initializeWebImage() + cookieClient.removeYay() + cookieClient.syncExCookies() + cookieClient.ignoreOffensive() + cookieClient.fulfillAnotherHostField() + await send(.migration(.prepareDatabase)) + } case .removeExpiredImageURLs: - return databaseClient.removeExpiredImageURLs().fireAndForget() + return .run(operation: { _ in await databaseClient.removeExpiredImageURLs() }) case .migration: return .none @@ -55,9 +55,9 @@ struct AppDelegateReducer: ReducerProtocol { class AppDelegate: UIResponder, UIApplicationDelegate { let store = Store( initialState: .init(), - reducer: AppReducer() + reducer: AppReducer.init ) - lazy var viewStore = ViewStore(store) + lazy var viewStore = ViewStore(store, observe: { $0 }) static var orientationMask: UIInterfaceOrientationMask = DeviceUtil.isPad ? .all : [.portrait, .portraitUpsideDown] diff --git a/EhPanda/DataFlow/AppLockReducer.swift b/EhPanda/DataFlow/AppLockReducer.swift index daafcc15..2709fa9b 100644 --- a/EhPanda/DataFlow/AppLockReducer.swift +++ b/EhPanda/DataFlow/AppLockReducer.swift @@ -39,11 +39,11 @@ struct AppLockReducer: ReducerProtocol { Date.now.timeIntervalSince(date) >= Double(threshold) { return .merge( - .init(value: .authorize), - .init(value: .lockApp(blurRadius)) + .send(.authorize), + .send(.lockApp(blurRadius)) ) } else { - return .init(value: .unlockApp) + return .send(.unlockApp) } case .onBecomeInactive(let blurRadius): @@ -63,11 +63,13 @@ struct AppLockReducer: ReducerProtocol { return .none case .authorize: - return authorizationClient.localAuthroize(L10n.Localizable.LocalAuthorization.reason) - .map(Action.authorizeDone) + return .run { send in + let reason = L10n.Localizable.LocalAuthorization.reason + await send(.authorizeDone(authorizationClient.localAuthroize(reason))) + } case .authorizeDone(let isSucceeded): - return isSucceeded ? .init(value: .unlockApp) : .none + return isSucceeded ? .send(.unlockApp) : .none } } } diff --git a/EhPanda/DataFlow/AppReducer.swift b/EhPanda/DataFlow/AppReducer.swift index f67bca86..75640e80 100644 --- a/EhPanda/DataFlow/AppReducer.swift +++ b/EhPanda/DataFlow/AppReducer.swift @@ -47,10 +47,10 @@ struct AppReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.appRouteState.$route): - return state.appRouteState.route == nil ? .init(value: .appRoute(.clearSubStates)) : .none + return state.appRouteState.route == nil ? .send(.appRoute(.clearSubStates)) : .none case .binding(\.settingState.$setting): - return .init(value: .setting(.syncSetting)) + return .send(.setting(.syncSetting)) case .binding: return .none @@ -62,11 +62,11 @@ struct AppReducer: ReducerProtocol { case .active: let threshold = state.settingState.setting.autoLockPolicy.rawValue let blurRadius = state.settingState.setting.backgroundBlurRadius - return .init(value: .appLock(.onBecomeActive(threshold, blurRadius))) + return .send(.appLock(.onBecomeActive(threshold, blurRadius))) case .inactive: let blurRadius = state.settingState.setting.backgroundBlurRadius - return .init(value: .appLock(.onBecomeInactive(blurRadius))) + return .send(.appLock(.onBecomeInactive(blurRadius))) default: return .none @@ -74,18 +74,18 @@ struct AppReducer: ReducerProtocol { case .appDelegate(.migration(.onDatabasePreparationSuccess)): return .merge( - .init(value: .appDelegate(.removeExpiredImageURLs)), - .init(value: .setting(.loadUserSettings)) + .send(.appDelegate(.removeExpiredImageURLs)), + .send(.setting(.loadUserSettings)) ) case .appDelegate: return .none case .appRoute(.clearSubStates): - var effects = [EffectTask]() + var effects = [Effect]() if deviceClient.isPad() { state.settingState.route = nil - effects.append(.init(value: .setting(.clearSubStates))) + effects.append(.send(.setting(.clearSubStates))) } return effects.isEmpty ? .none : .merge(effects) @@ -93,11 +93,11 @@ struct AppReducer: ReducerProtocol { return .none case .appLock(.unlockApp): - var effects: [EffectTask] = [ - .init(value: .setting(.fetchGreeting)) + var effects: [Effect] = [ + .send(.setting(.fetchGreeting)) ] if state.settingState.setting.detectsLinksFromClipboard { - effects.append(.init(value: .appRoute(.detectClipboardURL))) + effects.append(.send(.appRoute(.detectClipboardURL))) } return .merge(effects) @@ -105,33 +105,33 @@ struct AppReducer: ReducerProtocol { return .none case .tabBar(.setTabBarItemType(let type)): - var effects = [EffectTask]() - let hapticEffect: EffectTask = .fireAndForget({ hapticsClient.generateFeedback(.soft) }) + var effects = [Effect]() + let hapticEffect: Effect = .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)))) + effects.append(.send(.home(.setNavigation(nil)))) } else { - effects.append(.init(value: .home(.fetchAllGalleries))) + effects.append(.send(.home(.fetchAllGalleries))) } case .favorites: if state.favoritesState.route != nil { - effects.append(.init(value: .favorites(.setNavigation(nil)))) + effects.append(.send(.favorites(.setNavigation(nil)))) effects.append(hapticEffect) } else if cookieClient.didLogin { - effects.append(.init(value: .favorites(.fetchGalleries()))) + effects.append(.send(.favorites(.fetchGalleries()))) effects.append(hapticEffect) } case .search: if state.searchRootState.route != nil { - effects.append(.init(value: .searchRoot(.setNavigation(nil)))) + effects.append(.send(.searchRoot(.setNavigation(nil)))) } else { - effects.append(.init(value: .searchRoot(.fetchDatabaseInfos))) + effects.append(.send(.searchRoot(.fetchDatabaseInfos))) } case .setting: if state.settingState.route != nil { - effects.append(.init(value: .setting(.setNavigation(nil)))) + effects.append(.send(.setting(.setNavigation(nil)))) effects.append(hapticEffect) } } @@ -140,7 +140,7 @@ struct AppReducer: ReducerProtocol { } } if type == .setting && deviceClient.isPad() { - effects.append(.init(value: .appRoute(.setNavigation(.setting)))) + effects.append(.send(.appRoute(.setNavigation(.setting)))) } return effects.isEmpty ? .none : .merge(effects) @@ -148,14 +148,14 @@ struct AppReducer: ReducerProtocol { return .none case .home(.watched(.onNotLoginViewButtonTapped)), .favorites(.onNotLoginViewButtonTapped): - var effects: [EffectTask] = [ + var effects: [Effect] = [ .fireAndForget({ hapticsClient.generateFeedback(.soft) }), - .init(value: .tabBar(.setTabBarItemType(.setting))) + .send(.tabBar(.setTabBarItemType(.setting))) ] - effects.append(.init(value: .setting(.setNavigation(.account)))) + effects.append(.send(.setting(.setNavigation(.account)))) if !cookieClient.didLogin { effects.append( - .init(value: .setting(.account(.setNavigation(.login)))) + .send(.setting(.account(.setNavigation(.login)))) .delay( for: .milliseconds(deviceClient.isPad() ? 1200 : 200), scheduler: DispatchQueue.main @@ -175,20 +175,20 @@ struct AppReducer: ReducerProtocol { return .none case .setting(.loadUserSettingsDone): - var effects = [EffectTask]() + 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)))) + effects.append(.send(.appLock(.onBecomeActive(threshold, blurRadius)))) } if state.settingState.setting.detectsLinksFromClipboard { - effects.append(.init(value: .appRoute(.detectClipboardURL))) + effects.append(.send(.appRoute(.detectClipboardURL))) } return effects.isEmpty ? .none : .merge(effects) case .setting(.fetchGreetingDone(let result)): - return .init(value: .appRoute(.fetchGreetingDone(result))) + return .send(.appRoute(.fetchGreetingDone(result))) case .setting: return .none diff --git a/EhPanda/DataFlow/AppRouteReducer.swift b/EhPanda/DataFlow/AppRouteReducer.swift index 9b410b04..058d3127 100644 --- a/EhPanda/DataFlow/AppRouteReducer.swift +++ b/EhPanda/DataFlow/AppRouteReducer.swift @@ -52,6 +52,7 @@ struct AppRouteReducer: ReducerProtocol { @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.urlClient) private var urlClient + @Dependency(\.mainQueue) private var mainQueue var body: some ReducerProtocol { BindingReducer() @@ -59,14 +60,14 @@ struct AppRouteReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none + return state.route == nil ? .send(.clearSubStates) : .none case .binding: return .none case .setNavigation(let route): state.route = route - return route == nil ? .init(value: .clearSubStates) : .none + return route == nil ? .send(.clearSubStates) : .none case .setHUDConfig(let config): state.hudConfig = config @@ -74,20 +75,20 @@ struct AppRouteReducer: ReducerProtocol { case .clearSubStates: state.detailState = .init() - return .init(value: .detail(.teardown)) + return .send(.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))) + .getValue(.clipboardChangeCount) + else { return .none } + + return .run { send in + userDefaultsClient.setValue(currentChangeCount, .clipboardChangeCount) + if let url = clipboardClient.url() { + await send(.handleDeepLink(url)) + } } - return .merge(effects) case .handleDeepLink(let url): let url = urlClient.resolveAppSchemeURL(url) ?? url @@ -100,39 +101,47 @@ struct AppRouteReducer: ReducerProtocol { } 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 .run { [delay] send in + try await mainQueue.sleep(for: .milliseconds(delay)) + if await databaseClient.fetchGallery(gid: gid) == nil { + try await mainQueue.sleep(for: .milliseconds(250)) + await send(.handleGalleryLink(url)) + } else { + await send(.fetchGallery(url, isGalleryImageURL)) + } } - 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]() + var effects = [Effect]() state.detailState = .init() - effects.append(.init(value: .detail(.fetchDatabaseInfos(gid)))) + effects.append(.send(.detail(.fetchDatabaseInfos(gid)))) if let pageIndex = pageIndex { - effects.append(.init(value: .updateReadingProgress(gid, pageIndex))) + effects.append(.send(.updateReadingProgress(gid, pageIndex))) effects.append( - .init(value: .detail(.setNavigation(.reading))) - .delay(for: .milliseconds(500), scheduler: DispatchQueue.main).eraseToEffect() + .run { send in + try await mainQueue.sleep(for: .milliseconds(500)) + await send(.detail(.setNavigation(.reading))) + } ) } 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() + .run { send in + try await mainQueue.sleep(for: .milliseconds(500)) + await send(.detail(.setNavigation(.comments(url)))) + } ) } - effects.append(.init(value: .setNavigation(.detail(gid)))) + effects.append(.send(.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() + return .run { _ in + await databaseClient.cacheReadingProgress(gid: gid, progress: progress) + } case .fetchGallery(let url, let isGalleryImageURL): state.route = .hud @@ -143,18 +152,20 @@ struct AppRouteReducer: ReducerProtocol { state.route = nil switch result { case .success(let gallery): - return .merge( - databaseClient.cacheGalleries([gallery]).fireAndForget(), - .init(value: .handleGalleryLink(url)) - ) + return .run { send in + await databaseClient.cacheGalleries([gallery]) + await send(.handleGalleryLink(url)) + } case .failure: - return .init(value: .setHUDConfig(.error)) - .delay(for: .milliseconds(500), scheduler: DispatchQueue.main).eraseToEffect() + return .run { send in + try await mainQueue.sleep(for: .milliseconds(500)) + await send(.setHUDConfig(.error)) + } } case .fetchGreetingDone(let result): if case .success(let greeting) = result, !greeting.gainedNothing { - return .init(value: .setNavigation(.newDawn(greeting))) + return .send(.setNavigation(.newDawn(greeting))) } return .none diff --git a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift index 3c4ec1e1..3a4f8f2c 100644 --- a/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/AppEnvMO+CoreDataClass.swift @@ -9,17 +9,17 @@ import CoreData public class AppEnvMO: NSManagedObject {} -extension AppEnvMO: ManagedObjectProtocol { - func toEntity() -> AppEnv { - AppEnv( - user: user?.toObject() ?? User(), - setting: setting?.toObject() ?? Setting(), - searchFilter: searchFilter?.toObject() ?? Filter(), - globalFilter: globalFilter?.toObject() ?? Filter(), - watchedFilter: watchedFilter?.toObject() ?? Filter(), - tagTranslator: tagTranslator?.toObject() ?? TagTranslator(), - historyKeywords: historyKeywords?.toObject() ?? [String](), - quickSearchWords: quickSearchWords?.toObject() ?? [QuickSearchWord]() +extension AppEnvMO: ModelConvertible { + func toModel() -> AppEnv { + .init( + user: user?.toObject() ?? .init(), + setting: setting?.toObject() ?? .init(), + searchFilter: searchFilter?.toObject() ?? .init(), + globalFilter: globalFilter?.toObject() ?? .init(), + watchedFilter: watchedFilter?.toObject() ?? .init(), + tagTranslator: tagTranslator?.toObject() ?? .init(), + historyKeywords: historyKeywords?.toObject() ?? .init(), + quickSearchWords: quickSearchWords?.toObject() ?? .init() ) } } diff --git a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift index fffb307c..30f7cde5 100644 --- a/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/GalleryDetailMO+CoreDataClass.swift @@ -9,19 +9,29 @@ import CoreData public class GalleryDetailMO: NSManagedObject {} -extension GalleryDetailMO: ManagedObjectProtocol { - func toEntity() -> GalleryDetail { - GalleryDetail( - gid: gid, title: title, jpnTitle: jpnTitle, isFavorited: isFavorited, - visibility: visibility?.toObject() ?? GalleryVisibility.yes, - rating: rating, userRating: userRating, ratingCount: Int(ratingCount), - category: Category(rawValue: category).forceUnwrapped, - language: Language(rawValue: language).forceUnwrapped, - uploader: uploader, postedDate: postedDate, - coverURL: coverURL, archiveURL: archiveURL, parentURL: parentURL, - favoritedCount: Int(favoritedCount), pageCount: Int(pageCount), - sizeCount: sizeCount, sizeType: sizeType, - torrentCount: Int(torrentCount) +extension GalleryDetailMO: ModelConvertible { + func toModel() -> GalleryDetail { + .init( + gid: gid, + title: title, + jpnTitle: jpnTitle, + isFavorited: isFavorited, + visibility: visibility?.toObject() ?? .yes, + rating: rating, + userRating: userRating, + ratingCount: .init(ratingCount), + category: .init(rawValue: category).forceUnwrapped, + language: .init(rawValue: language).forceUnwrapped, + uploader: uploader, + postedDate: postedDate, + coverURL: coverURL, + archiveURL: archiveURL, + parentURL: parentURL, + favoritedCount: .init(favoritedCount), + pageCount: .init(pageCount), + sizeCount: sizeCount, + sizeType: sizeType, + torrentCount: .init(torrentCount) ) } } diff --git a/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift index 8ed1db85..10afb991 100644 --- a/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/GalleryMO+CoreDataClass.swift @@ -9,16 +9,20 @@ import CoreData public class GalleryMO: NSManagedObject {} -extension GalleryMO: ManagedObjectProtocol { - func toEntity() -> Gallery { +extension GalleryMO: ModelConvertible { + func toModel() -> Gallery { Gallery( - gid: gid, token: token, - title: title, rating: rating, - tags: tags?.toObject() ?? [GalleryTag](), - category: Category(rawValue: category) ?? .private, - uploader: uploader, pageCount: Int(pageCount), + gid: gid, + token: token, + title: title, + rating: rating, + tags: tags?.toObject() ?? .init(), + category: .init(rawValue: category) ?? .private, + uploader: uploader, + pageCount: .init(pageCount), postedDate: postedDate, - coverURL: coverURL, galleryURL: galleryURL, + coverURL: coverURL, + galleryURL: galleryURL, lastOpenDate: lastOpenDate ) } diff --git a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift index 096cd94e..a9804b57 100644 --- a/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift +++ b/EhPanda/Database/MODefinition/GalleryStateMO+CoreDataClass.swift @@ -10,17 +10,17 @@ import CoreData public class GalleryStateMO: NSManagedObject {} -extension GalleryStateMO: ManagedObjectProtocol { - func toEntity() -> GalleryState { +extension GalleryStateMO: ModelConvertible { + func toModel() -> GalleryState { GalleryState( - gid: gid, tags: tags?.toObject() ?? [GalleryTag](), - readingProgress: Int(readingProgress), - previewURLs: previewURLs?.toObject() ?? [Int: URL](), - previewConfig: previewConfig?.toObject() ?? PreviewConfig.normal(rows: 4), - comments: comments?.toObject() ?? [GalleryComment](), - imageURLs: imageURLs?.toObject() ?? [Int: URL](), - originalImageURLs: originalImageURLs?.toObject() ?? [Int: URL](), - thumbnailURLs: thumbnailURLs?.toObject() ?? [Int: URL]() + gid: gid, tags: tags?.toObject() ?? .init(), + readingProgress: .init(readingProgress), + previewURLs: previewURLs?.toObject() ?? .init(), + previewConfig: previewConfig?.toObject() ?? .normal(rows: 4), + comments: comments?.toObject() ?? .init(), + imageURLs: imageURLs?.toObject() ?? .init(), + originalImageURLs: originalImageURLs?.toObject() ?? .init(), + thumbnailURLs: thumbnailURLs?.toObject() ?? .init() ) } } diff --git a/EhPanda/Database/Persistence.swift b/EhPanda/Database/Persistence.swift index 7ab83b1e..e05516f5 100644 --- a/EhPanda/Database/Persistence.swift +++ b/EhPanda/Database/Persistence.swift @@ -22,79 +22,69 @@ struct PersistenceController { // MARK: Preparation extension PersistenceController { - func prepare(completion: @escaping (Result) -> Void) { + public func prepare() async -> AppError? { do { - try loadPersistentStore(completion: completion) + return try await loadPersistentStore() } catch { - completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) + return error as? AppError ?? .databaseCorrupted(nil) } } - func rebuild(completion: @escaping (Result) -> Void) { + + public func rebuild() async -> AppError? { guard let storeURL = container.persistentStoreDescriptions.first?.url else { - completion(.failure(.databaseCorrupted("PersistentContainer was not set up properly."))) - return + return .databaseCorrupted("PersistentContainer was not set up properly.") } - DispatchQueue.global(qos: .userInitiated).async { - do { - try NSPersistentStoreCoordinator.destroyStore(at: storeURL) - } catch { - completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) - } - container.loadPersistentStores { _, error in - guard error == nil else { - let message = "Was unable to load store \(String(describing: error))." - completion(.failure(.databaseCorrupted(message))) - return - } - completion(.success(())) - } + do { + try NSPersistentStoreCoordinator.destroyStore(at: storeURL) + } catch { + return error as? AppError ?? .databaseCorrupted(nil) } + return await container.loadPersistentStoresAsync() } - private func loadPersistentStore(completion: @escaping (Result) -> Void) throws { - try migrateStoreIfNeeded { result in - switch result { - case .success: - container.loadPersistentStores { _, error in - guard error == nil else { - let message = "Was unable to load store \(String(describing: error))." - completion(.failure(.databaseCorrupted(message))) - return - } - completion(.success(())) - } - case .failure(let error): - completion(.failure(error)) - } - } + + private func loadPersistentStore() async throws -> AppError? { + if let appError = try migrateStoreIfNeeded() { return appError } + guard container.persistentStoreCoordinator.persistentStores.isEmpty else { return nil } + return await container.loadPersistentStoresAsync() } - private func migrateStoreIfNeeded(completion: @escaping (Result) -> Void) throws { + + private func migrateStoreIfNeeded() throws -> AppError? { guard let storeURL = container.persistentStoreDescriptions.first?.url else { throw AppError.databaseCorrupted("PersistentContainer was not set up properly.") } - if try migrator.requiresMigration(at: storeURL, toVersion: try CoreDataMigrationVersion.current()) { - DispatchQueue.global(qos: .userInitiated).async { - do { - try migrator.migrateStore(at: storeURL, toVersion: try CoreDataMigrationVersion.current()) - } catch { - completion(.failure(error as? AppError ?? .databaseCorrupted(nil))) - } - completion(.success(())) + guard try migrator.requiresMigration(at: storeURL, toVersion: try CoreDataMigrationVersion.current()) + else { return nil } + + do { + try migrator.migrateStore(at: storeURL, toVersion: try CoreDataMigrationVersion.current()) + } catch { + return error as? AppError ?? .databaseCorrupted(nil) + } + return nil + } +} + +extension NSPersistentCloudKitContainer { + func loadPersistentStoresAsync() async -> AppError? { + await withCheckedContinuation { continuation in + loadPersistentStores { _, error in + continuation.resume( + returning: error.map({ .databaseCorrupted("Was unable to load store \($0).") }) + ) } - } else { - completion(.success(())) } } } // MARK: Definition -protocol ManagedObjectProtocol { - associatedtype Entity - func toEntity() -> Entity +protocol ModelConvertible { + associatedtype Model + func toModel() -> Model } protocol ManagedObjectConvertible { - associatedtype ManagedObject: NSManagedObject, ManagedObjectProtocol + associatedtype ManagedObject: NSManagedObject, ModelConvertible @discardableResult func toManagedObject(in context: NSManagedObjectContext) -> ManagedObject diff --git a/EhPanda/Models/Gallery/GalleryState.swift b/EhPanda/Models/Gallery/GalleryState.swift index f151b670..dd591daf 100644 --- a/EhPanda/Models/Gallery/GalleryState.swift +++ b/EhPanda/Models/Gallery/GalleryState.swift @@ -9,8 +9,10 @@ import SwiftUI import Foundation struct GalleryState: Codable { - static let empty = GalleryState(gid: "") - static let preview = GalleryState(gid: "") + static let empty = empty(gid: "") + static let preview = empty(gid: "") + + static func empty(gid: String) -> Self { .init(gid: gid) } let gid: String var tags = [GalleryTag]() diff --git a/EhPanda/Models/Persistent/AppEnv.swift b/EhPanda/Models/Persistent/AppEnv.swift index e77dafef..8384fc4a 100644 --- a/EhPanda/Models/Persistent/AppEnv.swift +++ b/EhPanda/Models/Persistent/AppEnv.swift @@ -14,6 +14,17 @@ struct AppEnv: Codable, Equatable { let tagTranslator: TagTranslator let historyKeywords: [String] let quickSearchWords: [QuickSearchWord] + + static let empty: Self = .init( + user: .init(), + setting: .init(), + searchFilter: .init(), + globalFilter: .init(), + watchedFilter: .init(), + tagTranslator: .init(), + historyKeywords: .init(), + quickSearchWords: .init() + ) } extension AppEnv: CustomStringConvertible { diff --git a/EhPanda/Models/Tags/TagDetail.swift b/EhPanda/Models/Tags/TagDetail.swift index 3300a0ee..fb7a13c9 100644 --- a/EhPanda/Models/Tags/TagDetail.swift +++ b/EhPanda/Models/Tags/TagDetail.swift @@ -12,11 +12,4 @@ struct TagDetail: Equatable { let description: String let imageURLs: [URL] let links: [URL] - - init(title: String, description: String, imageURLs: [URL], links: [URL]) { - self.title = title - self.description = description - self.imageURLs = imageURLs - self.links = links - } } diff --git a/EhPanda/Network/Request.swift b/EhPanda/Network/Request.swift index d739ee09..dca1064e 100644 --- a/EhPanda/Network/Request.swift +++ b/EhPanda/Network/Request.swift @@ -16,10 +16,34 @@ protocol Request { var publisher: AnyPublisher { get } } extension Request { - var effect: EffectTask> { + var effect: Effect> { publisher.receive(on: DispatchQueue.main).catchToEffect() } + func process() async -> Result { + await withCheckedContinuation { continuation in + var cancellable: AnyCancellable? + var finishedWithoutValue = true + + cancellable = publisher + .first() + .sink { result in + switch result { + case .finished: + if finishedWithoutValue { + continuation.resume(returning: .failure(.networkingFailed)) + } + case let .failure(error): + continuation.resume(returning: .failure(error)) + } + cancellable?.cancel() + } receiveValue: { value in + finishedWithoutValue = false + continuation.resume(returning: .success(value)) + } + } + } + func mapAppError(error: Error) -> AppError { switch error { case is ParseError: diff --git a/EhPanda/View/Detail/Archives/ArchivesReducer.swift b/EhPanda/View/Detail/Archives/ArchivesReducer.swift index 3e180958..1eb5ea75 100644 --- a/EhPanda/View/Detail/Archives/ArchivesReducer.swift +++ b/EhPanda/View/Detail/Archives/ArchivesReducer.swift @@ -62,8 +62,9 @@ struct ArchivesReducer: ReducerProtocol { return .none case .syncGalleryFunds(let galleryPoints, let credits): - return databaseClient - .updateGalleryFunds(galleryPoints: galleryPoints, credits: credits).fireAndForget() + return .run { _ in + await databaseClient.cacheGalleryFunds(galleryPoints: galleryPoints, credits: credits) + } case .teardown: return .cancel(ids: CancelID.allCases) @@ -85,9 +86,9 @@ struct ArchivesReducer: ReducerProtocol { } state.hathArchives = archive.hathArchives if let galleryPoints = galleryPoints, let credits = credits { - return .init(value: .syncGalleryFunds(galleryPoints, credits)) + return .send(.syncGalleryFunds(galleryPoints, credits)) } else if cookieClient.isSameAccount { - return .init(value: .fetchArchiveFunds(gid, galleryURL)) + return .send(.fetchArchiveFunds(gid, galleryURL)) } else { return .none } @@ -103,7 +104,7 @@ struct ArchivesReducer: ReducerProtocol { case .fetchArchiveFundsDone(let result): if case .success(let (galleryPoints, credits)) = result { - return .init(value: .syncGalleryFunds(galleryPoints, credits)) + return .send(.syncGalleryFunds(galleryPoints, credits)) } return .none @@ -140,7 +141,9 @@ struct ArchivesReducer: ReducerProtocol { state.messageHUDConfig = .error isSuccess = false } - return .fireAndForget({ hapticsClient.generateNotificationFeedback(isSuccess ? .success : .error) }) + return .run { _ in + hapticsClient.generateNotificationFeedback(isSuccess ? .success : .error) + } } } } diff --git a/EhPanda/View/Detail/Archives/ArchivesView.swift b/EhPanda/View/Detail/Archives/ArchivesView.swift index 9ae731f3..6fca0051 100644 --- a/EhPanda/View/Detail/Archives/ArchivesView.swift +++ b/EhPanda/View/Detail/Archives/ArchivesView.swift @@ -21,7 +21,7 @@ struct ArchivesView: View { gid: String, user: User, galleryURL: URL, archiveURL: URL ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.gid = gid self.user = user self.galleryURL = galleryURL @@ -33,7 +33,7 @@ struct ArchivesView: View { NavigationView { ZStack { VStack { - HathArchivesView(archives: viewStore.hathArchives, selection: viewStore.binding(\.$selectedArchive)) + HathArchivesView(archives: viewStore.hathArchives, selection: viewStore.$selectedArchive) Spacer() if let credits = Int(user.credits ?? ""), let galleryPoints = Int(user.galleryPoints ?? "") { ArchiveFundsView(credits: credits, galleryPoints: galleryPoints) @@ -55,12 +55,12 @@ struct ArchivesView: View { } .progressHUD( config: viewStore.communicatingHUDConfig, - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /ArchivesReducer.Route.communicatingHUD ) .progressHUD( config: viewStore.messageHUDConfig, - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /ArchivesReducer.Route.messageHUD ) .animation(.default, value: viewStore.hathArchives) @@ -223,7 +223,7 @@ struct ArchivesView_Previews: PreviewProvider { ArchivesView( store: .init( initialState: .init(), - reducer: ArchivesReducer() + reducer: ArchivesReducer.init ), gid: .init(), user: .init(), diff --git a/EhPanda/View/Detail/Comments/CommentsReducer.swift b/EhPanda/View/Detail/Comments/CommentsReducer.swift index 98ebffaf..4e2b1bec 100644 --- a/EhPanda/View/Detail/Comments/CommentsReducer.swift +++ b/EhPanda/View/Detail/Comments/CommentsReducer.swift @@ -69,6 +69,7 @@ struct CommentsReducer: ReducerProtocol { @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient @Dependency(\.urlClient) private var urlClient + @Dependency(\.mainQueue) private var mainQueue var body: some ReducerProtocol { BindingReducer() @@ -76,20 +77,20 @@ struct CommentsReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none + return state.route == nil ? .send(.clearSubStates) : .none case .binding: return .none case .setNavigation(let route): state.route = route - return route == nil ? .init(value: .clearSubStates) : .none + return route == nil ? .send(.clearSubStates) : .none case .clearSubStates: state.detailState = .init() state.commentContent = .init() state.postCommentFocused = false - return .init(value: .detail(.teardown)) + return .send(.detail(.teardown)) case .clearScrollCommentID: state.scrollCommentID = nil @@ -112,60 +113,69 @@ struct CommentsReducer: ReducerProtocol { 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() - ) + return .run { send in + await send(.setScrollRowOpacity(0.25)) + try await mainQueue.sleep(for: .milliseconds(750)) + await send(.setScrollRowOpacity(1)) + try await mainQueue.sleep(for: .milliseconds(500)) + await send(.clearScrollCommentID) + try await mainQueue.sleep(for: .milliseconds(750)) + } 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 .run { send in + if urlClient.checkIfHandleable(url) { + let (isGalleryImageURL, _, _) = urlClient.analyzeURL(url) + let gid = urlClient.parseGalleryID(url) + if await databaseClient.fetchGallery(gid: gid) == nil { + await send(.fetchGallery(url, isGalleryImageURL)) + } else { + await send(.handleGalleryLink(url)) + } + } else { + uiApplicationClient.openURL(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]() + var effects = [Effect]() if let pageIndex = pageIndex { - effects.append(.init(value: .updateReadingProgress(gid, pageIndex))) + effects.append(.send(.updateReadingProgress(gid, pageIndex))) effects.append( - .init(value: .detail(.setNavigation(.reading))) - .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect() + .run { send in + try await mainQueue.sleep(for: .milliseconds(750)) + await send(.detail(.setNavigation(.reading))) + } ) } 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() + .run { send in + try await mainQueue.sleep(for: .milliseconds(750)) + await send(.detail(.setNavigation(.comments(url)))) + } ) } - effects.append(.init(value: .setNavigation(.detail(gid)))) + effects.append(.send(.setNavigation(.detail(gid)))) return .merge(effects) case .onPostCommentAppear: - return .init(value: .setPostCommentFocused(true)) - .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect() + return .run { send in + try await mainQueue.sleep(for: .milliseconds(750)) + await send(.setPostCommentFocused(true)) + } case .onAppear: if state.detailState == nil { state.detailState = .init() } - return state.scrollCommentID != nil ? .init(value: .performScrollOpacityEffect) : .none + return state.scrollCommentID != nil ? .send(.performScrollOpacityEffect) : .none case .updateReadingProgress(let gid, let progress): guard !gid.isEmpty else { return .none } - return databaseClient - .updateReadingProgress(gid: gid, progress: progress).fireAndForget() + return .run(operation: { _ in await databaseClient.cacheReadingProgress(gid: gid, progress: progress) }) case .teardown: return .cancel(ids: CancelID.allCases) @@ -204,13 +214,15 @@ struct CommentsReducer: ReducerProtocol { state.route = nil switch result { case .success(let gallery): - return .merge( - databaseClient.cacheGalleries([gallery]).fireAndForget(), - .init(value: .handleGalleryLink(url)) - ) + return .run { send in + await databaseClient.cacheGalleries([gallery]) + await send(.handleGalleryLink(url)) + } case .failure: - return .init(value: .setHUDConfig(.error)) - .delay(for: .milliseconds(500), scheduler: DispatchQueue.main).eraseToEffect() + return .run { send in + try await mainQueue.sleep(for: .milliseconds(500)) + await send(.setHUDConfig(.error)) + } } case .detail: diff --git a/EhPanda/View/Detail/Comments/CommentsView.swift b/EhPanda/View/Detail/Comments/CommentsView.swift index 27534aab..16a68971 100644 --- a/EhPanda/View/Detail/Comments/CommentsView.swift +++ b/EhPanda/View/Detail/Comments/CommentsView.swift @@ -29,7 +29,7 @@ struct CommentsView: View { blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.gid = gid self.token = token self.apiKey = apiKey @@ -92,14 +92,14 @@ struct CommentsView: View { } } } - .sheet(unwrapping: viewStore.binding(\.$route), case: /CommentsReducer.Route.postComment) { route in + .sheet(unwrapping: viewStore.$route, case: /CommentsReducer.Route.postComment) { route in let hasCommentID = !route.wrappedValue.isEmpty PostCommentView( title: hasCommentID ? L10n.Localizable.PostCommentView.Title.editComment : L10n.Localizable.PostCommentView.Title.postComment, - content: viewStore.binding(\.$commentContent), - isFocused: viewStore.binding(\.$postCommentFocused), + content: viewStore.$commentContent, + isFocused: viewStore.$postCommentFocused, postAction: { if hasCommentID { viewStore.send(.postComment(galleryURL, route.wrappedValue)) @@ -116,7 +116,7 @@ struct CommentsView: View { } .progressHUD( config: viewStore.hudConfig, - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /CommentsReducer.Route.hud ) .animation(.default, value: viewStore.scrollRowOpacity) @@ -143,7 +143,7 @@ struct CommentsView: View { // MARK: NavigationLinks private extension CommentsView { @ViewBuilder var navigationLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /CommentsReducer.Route.detail) { route in + NavigationLink(unwrapping: viewStore.$route, case: /CommentsReducer.Route.detail) { route in DetailView( store: store.scope(state: \.detailState, action: CommentsReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, @@ -274,7 +274,7 @@ struct CommentsView_Previews: PreviewProvider { CommentsView( store: .init( initialState: .init(), - reducer: CommentsReducer() + reducer: CommentsReducer.init ), gid: .init(), token: .init(), diff --git a/EhPanda/View/Detail/DetailReducer.swift b/EhPanda/View/Detail/DetailReducer.swift index 67b0de73..deda3959 100644 --- a/EhPanda/View/Detail/DetailReducer.swift +++ b/EhPanda/View/Detail/DetailReducer.swift @@ -114,6 +114,7 @@ struct DetailReducer: ReducerProtocol { @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient @Dependency(\.cookieClient) private var cookieClient + @Dependency(\.mainQueue) private var mainQueue var body: some ReducerProtocol { RecurseReducer { (self) in @@ -122,14 +123,14 @@ struct DetailReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none + return state.route == nil ? .send(.clearSubStates) : .none case .binding: return .none case .setNavigation(let route): state.route = route - return route == nil ? .init(value: .clearSubStates) : .none + return route == nil ? .send(.clearSubStates) : .none case .clearSubStates: state.readingState = .init() @@ -142,16 +143,16 @@ struct DetailReducer: ReducerProtocol { 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)) + .send(.reading(.teardown)), + .send(.archives(.teardown)), + .send(.torrents(.teardown)), + .send(.previews(.teardown)), + .send(.comments(.teardown)), + .send(.detailSearch(.teardown)) ) case .onPostCommentAppear: - return .init(value: .setPostCommentFocused(true)) + return .send(.setPostCommentFocused(true)) .delay(for: .milliseconds(750), scheduler: DispatchQueue.main).eraseToEffect() case .onAppear(let gid, let showsNewDawnGreeting): @@ -162,15 +163,15 @@ struct DetailReducer: ReducerProtocol { if state.commentsState == nil { state.commentsState = .init() } - return .init(value: .fetchDatabaseInfos(gid)) + return .send(.fetchDatabaseInfos(gid)) case .toggleShowFullTitle: state.showsFullTitle.toggle() - return .fireAndForget({ hapticsClient.generateFeedback(.soft) }) + return .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) case .toggleShowUserRating: state.showsUserRating.toggle() - return .fireAndForget({ hapticsClient.generateFeedback(.soft) }) + return .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) case .setCommentContent(let content): state.commentContent = content @@ -186,66 +187,81 @@ struct DetailReducer: ReducerProtocol { 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() - ) + return .run { send in + await send(.rateGallery) + hapticsClient.generateFeedback(.soft) + try await mainQueue.sleep(for: .seconds(1)) + } case .confirmRatingDone: state.showsUserRating = false return .none case .syncGalleryTags: - return databaseClient - .updateGalleryTags(gid: state.gallery.id, tags: state.galleryTags).fireAndForget() + let gid = state.gallery.id + let galleryTags = state.galleryTags + return .run(operation: { _ in await databaseClient.cacheGalleryTags(gid: gid, tags: galleryTags) }) case .syncGalleryDetail: guard let detail = state.galleryDetail else { return .none } - return databaseClient.cacheGalleryDetail(detail).fireAndForget() + return .run(operation: { _ in await databaseClient.cacheGalleryDetail(detail) }) case .syncGalleryPreviewURLs: - return databaseClient - .updatePreviewURLs(gid: state.gallery.id, previewURLs: state.galleryPreviewURLs).fireAndForget() + let gid = state.gallery.id + let galleryPreviewURLs = state.galleryPreviewURLs + return .run { _ in + await databaseClient.cachePreviewURLs(gid: gid, previewURLs: galleryPreviewURLs) + } case .syncGalleryComments: - return databaseClient - .updateComments(gid: state.gallery.id, comments: state.galleryComments).fireAndForget() + let gid = state.gallery.id + let galleryComments = state.galleryComments + return .run { _ in + await databaseClient.cacheComments(gid: gid, comments: galleryComments) + } case .syncGreeting(let greeting): - return databaseClient.updateGreeting(greeting).fireAndForget() + return .run(operation: { _ in await databaseClient.cacheGreeting(greeting) }) case .syncPreviewConfig(let config): - return databaseClient - .updatePreviewConfig(gid: state.gallery.id, config: config).fireAndForget() + let gid = state.gallery.id + return .run { _ in + await databaseClient.cachePreviewConfig(gid: gid, config: config) + } case .saveGalleryHistory: - return databaseClient.updateLastOpenDate(gid: state.gallery.id).fireAndForget() + let gid = state.gallery.id + return .run(operation: { _ in await databaseClient.updateLastOpenDate(gid: gid) }) case .updateReadingProgress(let progress): - return databaseClient - .updateReadingProgress(gid: state.gallery.id, progress: progress).fireAndForget() + let gid = state.gallery.id + return .run { _ in + await databaseClient.cacheReadingProgress(gid: gid, progress: progress) + } 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 + // TODO: Fix me later +// guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } +// state.gallery = gallery +// if let detail = databaseClient.fetchGalleryDetail(gid: gid) { +// state.galleryDetail = detail +// } + let gid = state.gallery.id + return .run { send in + await send(.saveGalleryHistory) + let galleryState = await databaseClient.fetchGalleryState(gid: gid) + await send(.fetchDatabaseInfosDone(galleryState)) } - return .merge( - .init(value: .saveGalleryHistory), - databaseClient.fetchGalleryState(gid: state.gallery.id) - .map(Action.fetchDatabaseInfosDone).cancellable(id: CancelID.fetchDatabaseInfos) - ) + .cancellable(id: CancelID.fetchDatabaseInfos) case .fetchDatabaseInfosDone(let galleryState): state.galleryTags = galleryState.tags state.galleryPreviewURLs = galleryState.previewURLs state.galleryComments = galleryState.comments - return .init(value: .fetchGalleryDetail) + return .send(.fetchGalleryDetail) case .fetchGalleryDetail: guard state.loadingState != .loading, @@ -259,11 +275,11 @@ struct DetailReducer: ReducerProtocol { 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) + var effects: [Effect] = [ + .send(.syncGalleryTags), + .send(.syncGalleryDetail), + .send(.syncGalleryPreviewURLs), + .send(.syncGalleryComments) ] state.apiKey = apiKey state.galleryDetail = galleryDetail @@ -272,13 +288,13 @@ struct DetailReducer: ReducerProtocol { state.galleryComments = galleryState.comments state.userRating = Int(galleryDetail.userRating) * 2 if let greeting = greeting { - effects.append(.init(value: .syncGreeting(greeting))) + effects.append(.send(.syncGreeting(greeting))) if !greeting.gainedNothing && state.showsNewDawnGreeting { - effects.append(.init(value: .setNavigation(.newDawn(greeting)))) + effects.append(.send(.setNavigation(.newDawn(greeting)))) } } if let config = galleryState.previewConfig { - effects.append(.init(value: .syncPreviewConfig(config))) + effects.append(.send(.syncPreviewConfig(config))) } return .merge(effects) case .failure(let error): @@ -318,15 +334,15 @@ struct DetailReducer: ReducerProtocol { case .anyGalleryOpsDone(let result): if case .success = result { - return .merge( - .init(value: .fetchGalleryDetail), - .fireAndForget({ hapticsClient.generateNotificationFeedback(.success) }) - ) + return .run { send in + await send(.fetchGalleryDetail) + hapticsClient.generateNotificationFeedback(.success) + } } - return .fireAndForget({ hapticsClient.generateNotificationFeedback(.error) }) + return .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) case .reading(.onPerformDismiss): - return .init(value: .setNavigation(nil)) + return .send(.setNavigation(nil)) case .reading: return .none @@ -341,7 +357,7 @@ struct DetailReducer: ReducerProtocol { return .none case .comments(.performCommentActionDone(let result)): - return .init(value: .anyGalleryOpsDone(result)) + return .send(.anyGalleryOpsDone(result)) case .comments(.detail(let recursiveAction)): guard state.commentsState != nil else { return .none } diff --git a/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift b/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift index d1aa1d7b..c49f4149 100644 --- a/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift +++ b/EhPanda/View/Detail/DetailSearch/DetailSearchReducer.swift @@ -70,7 +70,7 @@ struct DetailSearchReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none + return state.route == nil ? .send(.clearSubStates) : .none case .binding(\.$keyword): if !state.keyword.isEmpty { @@ -83,15 +83,15 @@ struct DetailSearchReducer: ReducerProtocol { case .setNavigation(let route): state.route = route - return route == nil ? .init(value: .clearSubStates) : .none + return route == nil ? .send(.clearSubStates) : .none case .clearSubStates: state.detailState = .init() state.filtersState = .init() state.quickDetailSearchState = .init() return .merge( - .init(value: .detail(.teardown)), - .init(value: .quickSearch(.teardown)) + .send(.detail(.teardown)), + .send(.quickSearch(.teardown)) ) case .teardown: @@ -105,9 +105,13 @@ struct DetailSearchReducer: ReducerProtocol { } 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) + let lastKeyword = state.lastKeyword + return .run { send in + let filter = await databaseClient.fetchFilter(range: .search) + let result = await SearchGalleriesRequest(keyword: lastKeyword, filter: filter).process() + await send(.fetchGalleriesDone(result)) + } + .cancellable(id: CancelID.fetchGalleries) case .fetchGalleriesDone(let result): state.loadingState = .idle @@ -116,11 +120,11 @@ struct DetailSearchReducer: ReducerProtocol { guard !galleries.isEmpty else { state.loadingState = .failed(.notFound) guard pageNumber.hasNextPage() else { return .none } - return .init(value: .fetchMoreGalleries) + return .send(.fetchMoreGalleries) } state.pageNumber = pageNumber state.galleries = galleries - return databaseClient.cacheGalleries(galleries).fireAndForget() + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.loadingState = .failed(error) } @@ -133,10 +137,16 @@ struct DetailSearchReducer: ReducerProtocol { 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) + let lastKeyword = state.lastKeyword + return .run { send in + let filter = await databaseClient.fetchFilter(range: .search) + let result = await MoreSearchGalleriesRequest( + keyword: lastKeyword, filter: filter, lastID: lastID + ) + .process() + await send(.fetchMoreGalleriesDone(result)) + } + .cancellable(id: CancelID.fetchMoreGalleries) case .fetchMoreGalleriesDone(let result): state.footerLoadingState = .idle @@ -145,11 +155,11 @@ struct DetailSearchReducer: ReducerProtocol { state.pageNumber = pageNumber state.insertGalleries(galleries) - var effects: [EffectTask] = [ - databaseClient.cacheGalleries(galleries).fireAndForget() + var effects: [Effect] = [ + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) ] if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.init(value: .fetchMoreGalleries)) + effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { state.loadingState = .idle } diff --git a/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift b/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift index f8567c73..932caad3 100644 --- a/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift +++ b/EhPanda/View/Detail/DetailSearch/DetailSearchView.swift @@ -22,7 +22,7 @@ struct DetailSearchView: View { keyword: String, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.keyword = keyword self.user = user _setting = setting @@ -45,7 +45,7 @@ struct DetailSearchView: View { } ) .sheet( - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /DetailSearchReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in @@ -58,7 +58,7 @@ struct DetailSearchView: View { } .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailSearchReducer.Route.quickSearch) { _ in + .sheet(unwrapping: viewStore.$route, case: /DetailSearchReducer.Route.quickSearch) { _ in QuickSearchView( store: store.scope(state: \.quickDetailSearchState, action: DetailSearchReducer.Action.quickSearch) ) { keyword in @@ -68,14 +68,14 @@ struct DetailSearchView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailSearchReducer.Route.filters) { _ in + .sheet(unwrapping: viewStore.$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.$keyword) .searchSuggestions { TagSuggestionView( - keyword: viewStore.binding(\.$keyword), translations: tagTranslator.translations, + keyword: viewStore.$keyword, translations: tagTranslator.translations, showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion ) } @@ -96,7 +96,7 @@ struct DetailSearchView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailSearchReducer.Route.detail) { route in + NavigationLink(unwrapping: viewStore.$route, case: /DetailSearchReducer.Route.detail) { route in DetailView( store: store.scope(state: \.detailState, action: DetailSearchReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, @@ -124,7 +124,7 @@ struct DetailSearchView_Previews: PreviewProvider { DetailSearchView( store: .init( initialState: .init(), - reducer: DetailSearchReducer() + reducer: DetailSearchReducer.init ), keyword: .init(), user: .init(), diff --git a/EhPanda/View/Detail/DetailView.swift b/EhPanda/View/Detail/DetailView.swift index a5946b28..e8b7235a 100644 --- a/EhPanda/View/Detail/DetailView.swift +++ b/EhPanda/View/Detail/DetailView.swift @@ -24,7 +24,7 @@ struct DetailView: View { user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.gid = gid self.user = user _setting = setting @@ -121,7 +121,7 @@ struct DetailView: View { ErrorView(error: error ?? .unknown, action: error?.isRetryable != false ? retryAction : nil) .opacity(viewStore.galleryDetail == nil && error != nil ? 1 : 0) } - .fullScreenCover(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.reading) { _ in + .fullScreenCover(unwrapping: viewStore.$route, case: /DetailReducer.Route.reading) { _ in ReadingView( store: store.scope(state: \.readingState, action: DetailReducer.Action.reading), gid: gid, setting: $setting, blurRadius: blurRadius @@ -129,7 +129,7 @@ struct DetailView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.archives) { route in + .sheet(unwrapping: viewStore.$route, case: /DetailReducer.Route.archives) { route in let (galleryURL, archiveURL) = route.wrappedValue ArchivesView( store: store.scope(state: \.archivesState, action: DetailReducer.Action.archives), @@ -138,7 +138,7 @@ struct DetailView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.torrents) { _ in + .sheet(unwrapping: viewStore.$route, case: /DetailReducer.Route.torrents) { _ in TorrentsView( store: store.scope(state: \.torrentsState, action: DetailReducer.Action.torrents), gid: gid, token: viewStore.gallery.token, blurRadius: blurRadius @@ -146,15 +146,15 @@ struct DetailView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.share) { route in + .sheet(unwrapping: viewStore.$route, case: /DetailReducer.Route.share) { route in ActivityView(activityItems: [route.wrappedValue]) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.postComment) { _ in + .sheet(unwrapping: viewStore.$route, case: /DetailReducer.Route.postComment) { _ in PostCommentView( title: L10n.Localizable.PostCommentView.Title.postComment, - content: viewStore.binding(\.$commentContent), - isFocused: viewStore.binding(\.$postCommentFocused), + content: viewStore.$commentContent, + isFocused: viewStore.$postCommentFocused, postAction: { if let galleryURL = viewStore.gallery.galleryURL { viewStore.send(.postComment(galleryURL)) @@ -167,10 +167,10 @@ struct DetailView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.newDawn) { route in + .sheet(unwrapping: viewStore.$route, case: /DetailReducer.Route.newDawn) { route in NewDawnView(greeting: route.wrappedValue).autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.tagDetail) { route in + .sheet(unwrapping: viewStore.$route, case: /DetailReducer.Route.tagDetail) { route in TagDetailView(detail: route.wrappedValue).autoBlur(radius: blurRadius) } .animation(.default, value: viewStore.showsUserRating) @@ -189,13 +189,13 @@ struct DetailView: View { // MARK: NavigationLinks private extension DetailView { @ViewBuilder var navigationLinks: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.previews) { _ in + NavigationLink(unwrapping: viewStore.$route, case: /DetailReducer.Route.previews) { _ in PreviewsView( store: store.scope(state: \.previewsState, action: DetailReducer.Action.previews), gid: gid, setting: $setting, blurRadius: blurRadius ) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.comments) { route in + NavigationLink(unwrapping: viewStore.$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, @@ -205,7 +205,7 @@ private extension DetailView { ) } } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.detailSearch) { route in + NavigationLink(unwrapping: viewStore.$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, @@ -213,7 +213,7 @@ private extension DetailView { ) } } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /DetailReducer.Route.galleryInfos) { route in + NavigationLink(unwrapping: viewStore.$route, case: /DetailReducer.Route.galleryInfos) { route in let (gallery, galleryDetail) = route.wrappedValue GalleryInfosView( store: store.scope(state: \.galleryInfosState, action: DetailReducer.Action.galleryInfos), @@ -849,7 +849,7 @@ struct DetailView_Previews: PreviewProvider { DetailView( store: .init( initialState: .init(), - reducer: DetailReducer() + reducer: DetailReducer.init ), gid: .init(), user: .init(), diff --git a/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift b/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift index 9433f90b..488b655c 100644 --- a/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift +++ b/EhPanda/View/Detail/GalleryInfos/GalleryInfosReducer.swift @@ -36,10 +36,11 @@ struct GalleryInfosReducer: ReducerProtocol { case .copyText(let text): state.route = .hud - return .merge( - clipboardClient.saveText(text).fireAndForget(), - .fireAndForget({ hapticsClient.generateNotificationFeedback(.success) }) - ) + return .run { _ in + // TODO: Fix me later + await clipboardClient.saveText(text) + hapticsClient.generateNotificationFeedback(.success) + } } } } diff --git a/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift b/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift index 09500be6..672ee654 100644 --- a/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift +++ b/EhPanda/View/Detail/GalleryInfos/GalleryInfosView.swift @@ -16,7 +16,7 @@ struct GalleryInfosView: View { init(store: StoreOf, gallery: Gallery, galleryDetail: GalleryDetail) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.gallery = gallery self.galleryDetail = galleryDetail } @@ -118,7 +118,7 @@ struct GalleryInfosView: View { } .progressHUD( config: viewStore.hudConfig, - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /GalleryInfosReducer.Route.hud ) .navigationTitle(L10n.Localizable.GalleryInfosView.Title.galleryInfos) @@ -137,7 +137,7 @@ struct GalleryInfosView_Previews: PreviewProvider { GalleryInfosView( store: .init( initialState: .init(), - reducer: GalleryInfosReducer() + reducer: GalleryInfosReducer.init ), gallery: .preview, galleryDetail: .preview diff --git a/EhPanda/View/Detail/Previews/PreviewsReducer.swift b/EhPanda/View/Detail/Previews/PreviewsReducer.swift index ec25a37b..3597d858 100644 --- a/EhPanda/View/Detail/Previews/PreviewsReducer.swift +++ b/EhPanda/View/Detail/Previews/PreviewsReducer.swift @@ -62,35 +62,41 @@ struct PreviewsReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none + return state.route == nil ? .send(.clearSubStates) : .none case .binding: return .none case .setNavigation(let route): state.route = route - return route == nil ? .init(value: .clearSubStates) : .none + return route == nil ? .send(.clearSubStates) : .none case .clearSubStates: state.readingState = .init() - return .init(value: .reading(.teardown)) + return .send(.reading(.teardown)) case .syncPreviewURLs(let previewURLs): - return databaseClient - .updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs).fireAndForget() + let gid = state.gallery.id + return .run { _ in + await databaseClient.cachePreviewURLs(gid: gid, previewURLs: previewURLs) + } case .updateReadingProgress(let progress): - return databaseClient - .updateReadingProgress(gid: state.gallery.id, progress: progress).fireAndForget() + let gid = state.gallery.id + return .run(operation: { _ in await databaseClient.cacheReadingProgress(gid: gid, progress: progress) }) 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) + // TODO: Fix me later +// guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } +// state.gallery = gallery + let gid = state.gallery.id + return .run { send in + await send(.fetchDatabaseInfosDone(databaseClient.fetchGalleryState(gid: gid))) + } + .cancellable(id: CancelID.fetchDatabaseInfos) case .fetchDatabaseInfosDone(let galleryState): if let previewConfig = galleryState.previewConfig { @@ -119,14 +125,14 @@ struct PreviewsReducer: ReducerProtocol { return .none } state.updatePreviewURLs(previewURLs) - return .init(value: .syncPreviewURLs(previewURLs)) + return .send(.syncPreviewURLs(previewURLs)) case .failure(let error): state.loadingState = .failed(error) } return .none case .reading(.onPerformDismiss): - return .init(value: .setNavigation(nil)) + return .send(.setNavigation(nil)) case .reading: return .none diff --git a/EhPanda/View/Detail/Previews/PreviewsView.swift b/EhPanda/View/Detail/Previews/PreviewsView.swift index 1a255c68..24e67d79 100644 --- a/EhPanda/View/Detail/Previews/PreviewsView.swift +++ b/EhPanda/View/Detail/Previews/PreviewsView.swift @@ -21,7 +21,7 @@ struct PreviewsView: View { gid: String, setting: Binding, blurRadius: Double ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.gid = gid _setting = setting self.blurRadius = blurRadius @@ -68,7 +68,7 @@ struct PreviewsView: View { .padding(.bottom) .id(viewStore.databaseLoadingState) } - .fullScreenCover(unwrapping: viewStore.binding(\.$route), case: /PreviewsReducer.Route.reading) { _ in + .fullScreenCover(unwrapping: viewStore.$route, case: /PreviewsReducer.Route.reading) { _ in ReadingView( store: store.scope(state: \.readingState, action: PreviewsReducer.Action.reading), gid: gid, setting: $setting, blurRadius: blurRadius @@ -89,7 +89,7 @@ struct PreviewsView_Previews: PreviewProvider { PreviewsView( store: .init( initialState: .init(gallery: .preview), - reducer: PreviewsReducer() + reducer: PreviewsReducer.init ), gid: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Detail/Torrents/TorrentsReducer.swift b/EhPanda/View/Detail/Torrents/TorrentsReducer.swift index 24e2d304..46d43852 100644 --- a/EhPanda/View/Detail/Torrents/TorrentsReducer.swift +++ b/EhPanda/View/Detail/Torrents/TorrentsReducer.swift @@ -58,14 +58,15 @@ struct TorrentsReducer: ReducerProtocol { case .copyText(let magnetURL): state.route = .hud - return .merge( - clipboardClient.saveText(magnetURL).fireAndForget(), - .fireAndForget({ hapticsClient.generateNotificationFeedback(.success) }) - ) + return .run { _ in + // TODO: Fix me later + await clipboardClient.saveText(magnetURL) + 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 .send(.setNavigation(.share(url))) } return .none @@ -78,7 +79,7 @@ struct TorrentsReducer: ReducerProtocol { case .fetchTorrentDone(let hash, let result): if case .success(let data) = result, !data.isEmpty { - return .init(value: .presentTorrentActivity(hash, data)) + return .send(.presentTorrentActivity(hash, data)) } return .none diff --git a/EhPanda/View/Detail/Torrents/TorrentsView.swift b/EhPanda/View/Detail/Torrents/TorrentsView.swift index 5a8e1dc3..76de4a79 100644 --- a/EhPanda/View/Detail/Torrents/TorrentsView.swift +++ b/EhPanda/View/Detail/Torrents/TorrentsView.swift @@ -17,7 +17,7 @@ struct TorrentsView: View { init(store: StoreOf, gid: String, token: String, blurRadius: Double) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.gid = gid self.token = token self.blurRadius = blurRadius @@ -45,13 +45,13 @@ struct TorrentsView: View { } .opacity(error != nil && viewStore.torrents.isEmpty ? 1 : 0) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /TorrentsReducer.Route.share) { route in + .sheet(unwrapping: viewStore.$route, case: /TorrentsReducer.Route.share) { route in ActivityView(activityItems: [route.wrappedValue]) .autoBlur(radius: blurRadius) } .progressHUD( config: viewStore.hudConfig, - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /TorrentsReducer.Route.hud ) .animation(.default, value: viewStore.torrents) @@ -120,7 +120,7 @@ struct TorrentsView_Previews: PreviewProvider { TorrentsView( store: .init( initialState: .init(), - reducer: TorrentsReducer() + reducer: TorrentsReducer.init ), gid: .init(), token: .init(), diff --git a/EhPanda/View/Favorites/FavoritesReducer.swift b/EhPanda/View/Favorites/FavoritesReducer.swift index c2c694cf..ca19876c 100644 --- a/EhPanda/View/Favorites/FavoritesReducer.swift +++ b/EhPanda/View/Favorites/FavoritesReducer.swift @@ -81,23 +81,23 @@ struct FavoritesReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none + return state.route == nil ? .send(.clearSubStates) : .none case .binding: return .none case .setNavigation(let route): state.route = route - return route == nil ? .init(value: .clearSubStates) : .none + return route == nil ? .send(.clearSubStates) : .none case .setFavoritesIndex(let index): state.index = index guard state.galleries?.isEmpty != false else { return .none } - return .init(value: Action.fetchGalleries()) + return .send(.fetchGalleries()) case .clearSubStates: state.detailState = .init() - return .init(value: .detail(.teardown)) + return .send(.detail(.teardown)) case .onNotLoginViewButtonTapped: return .none @@ -125,12 +125,12 @@ struct FavoritesReducer: ReducerProtocol { guard !galleries.isEmpty else { state.rawLoadingState[targetFavIndex] = .failed(.notFound) guard pageNumber.hasNextPage() else { return .none } - return .init(value: .fetchMoreGalleries) + return .send(.fetchMoreGalleries) } state.rawPageNumber[targetFavIndex] = pageNumber state.rawGalleries[targetFavIndex] = galleries state.sortOrder = sortOrder - return databaseClient.cacheGalleries(galleries).fireAndForget() + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.rawLoadingState[targetFavIndex] = .failed(error) } @@ -160,11 +160,11 @@ struct FavoritesReducer: ReducerProtocol { state.insertGalleries(index: targetFavIndex, galleries: galleries) state.sortOrder = sortOrder - var effects: [EffectTask] = [ - databaseClient.cacheGalleries(galleries).fireAndForget() + var effects: [Effect] = [ + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) ] if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.init(value: .fetchMoreGalleries)) + effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { state.rawLoadingState[targetFavIndex] = .idle } diff --git a/EhPanda/View/Favorites/FavoritesView.swift b/EhPanda/View/Favorites/FavoritesView.swift index fea27eea..b2b91ad4 100644 --- a/EhPanda/View/Favorites/FavoritesView.swift +++ b/EhPanda/View/Favorites/FavoritesView.swift @@ -22,7 +22,7 @@ struct FavoritesView: View { user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.user = user _setting = setting self.blurRadius = blurRadius @@ -56,7 +56,7 @@ struct FavoritesView: View { } } .sheet( - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /FavoritesReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in @@ -69,7 +69,7 @@ struct FavoritesView: View { } .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /FavoritesReducer.Route.quickSearch) { _ in + .sheet(unwrapping: viewStore.$route, case: /FavoritesReducer.Route.quickSearch) { _ in QuickSearchView( store: store.scope(state: \.quickSearchState, action: FavoritesReducer.Action.quickSearch) ) { keyword in @@ -79,10 +79,10 @@ struct FavoritesView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .searchable(text: viewStore.binding(\.$keyword)) + .searchable(text: viewStore.$keyword) .searchSuggestions { TagSuggestionView( - keyword: viewStore.binding(\.$keyword), translations: tagTranslator.translations, + keyword: viewStore.$keyword, translations: tagTranslator.translations, showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion ) } @@ -104,7 +104,7 @@ struct FavoritesView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /FavoritesReducer.Route.detail) { route in + NavigationLink(unwrapping: viewStore.$route, case: /FavoritesReducer.Route.detail) { route in DetailView( store: store.scope(state: \.detailState, action: FavoritesReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, @@ -137,7 +137,7 @@ struct FavoritesView_Previews: PreviewProvider { FavoritesView( store: .init( initialState: .init(), - reducer: FavoritesReducer() + reducer: FavoritesReducer.init ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift index b4574355..f1739bc2 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageReducer.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageReducer.swift @@ -70,19 +70,19 @@ struct FrontpageReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none + return state.route == nil ? .send(.clearSubStates) : .none case .binding: return .none case .setNavigation(let route): state.route = route - return route == nil ? .init(value: .clearSubStates) : .none + return route == nil ? .send(.clearSubStates) : .none case .clearSubStates: state.detailState = .init() state.filtersState = .init() - return .init(value: .detail(.teardown)) + return .send(.detail(.teardown)) case .teardown: return .cancel(ids: CancelID.allCases) @@ -91,10 +91,12 @@ struct FrontpageReducer: ReducerProtocol { 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) + return .run { send in + let filter = await databaseClient.fetchFilter(range: .global) + let result = await FrontpageGalleriesRequest(filter: filter).process() + await send(.fetchGalleriesDone(result)) + } + .cancellable(id: CancelID.fetchGalleries) case .fetchGalleriesDone(let result): state.loadingState = .idle @@ -103,11 +105,11 @@ struct FrontpageReducer: ReducerProtocol { guard !galleries.isEmpty else { state.loadingState = .failed(.notFound) guard pageNumber.hasNextPage() else { return .none } - return .init(value: .fetchMoreGalleries) + return .send(.fetchMoreGalleries) } state.pageNumber = pageNumber state.galleries = galleries - return databaseClient.cacheGalleries(galleries).fireAndForget() + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.loadingState = .failed(error) } @@ -120,10 +122,12 @@ struct FrontpageReducer: ReducerProtocol { 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) + return .run { send in + let filter = await databaseClient.fetchFilter(range: .global) + let result = await MoreFrontpageGalleriesRequest(filter: filter, lastID: lastID).process() + await send(.fetchMoreGalleriesDone(result)) + } + .cancellable(id: CancelID.fetchMoreGalleries) case .fetchMoreGalleriesDone(let result): state.footerLoadingState = .idle @@ -132,11 +136,11 @@ struct FrontpageReducer: ReducerProtocol { state.pageNumber = pageNumber state.insertGalleries(galleries) - var effects: [EffectTask] = [ - databaseClient.cacheGalleries(galleries).fireAndForget() + var effects: [Effect] = [ + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) ] if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.init(value: .fetchMoreGalleries)) + effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { state.loadingState = .idle } diff --git a/EhPanda/View/Home/Frontpage/FrontpageView.swift b/EhPanda/View/Home/Frontpage/FrontpageView.swift index b82e137b..bf088fb1 100644 --- a/EhPanda/View/Home/Frontpage/FrontpageView.swift +++ b/EhPanda/View/Home/Frontpage/FrontpageView.swift @@ -22,7 +22,7 @@ struct FrontpageView: View { user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.user = user _setting = setting self.blurRadius = blurRadius @@ -44,7 +44,7 @@ struct FrontpageView: View { } ) .sheet( - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /FrontpageReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in @@ -57,11 +57,11 @@ struct FrontpageView: View { } .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /FrontpageReducer.Route.filters) { _ in + .sheet(unwrapping: viewStore.$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) + .searchable(text: viewStore.$keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) .onAppear { if viewStore.galleries.isEmpty { DispatchQueue.main.async { @@ -76,7 +76,7 @@ struct FrontpageView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /FrontpageReducer.Route.detail) { route in + NavigationLink(unwrapping: viewStore.$route, case: /FrontpageReducer.Route.detail) { route in DetailView( store: store.scope(state: \.detailState, action: FrontpageReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, @@ -100,7 +100,7 @@ struct FrontpageView_Previews: PreviewProvider { FrontpageView( store: .init( initialState: .init(), - reducer: FrontpageReducer() + reducer: FrontpageReducer.init ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Home/History/HistoryReducer.swift b/EhPanda/View/Home/History/HistoryReducer.swift index 547275c7..27242dd0 100644 --- a/EhPanda/View/Home/History/HistoryReducer.swift +++ b/EhPanda/View/Home/History/HistoryReducer.swift @@ -47,6 +47,7 @@ struct HistoryReducer: ReducerProtocol { @Dependency(\.databaseClient) private var databaseClient @Dependency(\.hapticsClient) private var hapticsClient + @Dependency(\.mainQueue) private var mainQueue var body: some ReducerProtocol { BindingReducer() @@ -54,30 +55,32 @@ struct HistoryReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none + return state.route == nil ? .send(.clearSubStates) : .none case .binding: return .none case .setNavigation(let route): state.route = route - return route == nil ? .init(value: .clearSubStates) : .none + return route == nil ? .send(.clearSubStates) : .none case .clearSubStates: state.detailState = .init() - return .init(value: .detail(.teardown)) + return .send(.detail(.teardown)) case .clearHistoryGalleries: - return .merge( - databaseClient.clearHistoryGalleries().fireAndForget(), - .init(value: .fetchGalleries) - .delay(for: .milliseconds(200), scheduler: DispatchQueue.main).eraseToEffect() - ) + return .run { send in + await databaseClient.clearHistoryGalleries() + try await mainQueue.sleep(for: .milliseconds(200)) + await send(.fetchGalleries) + } case .fetchGalleries: guard state.loadingState != .loading else { return .none } state.loadingState = .loading - return databaseClient.fetchHistoryGalleries().map(Action.fetchGalleriesDone) + return .run { send in + await send(.fetchGalleriesDone(databaseClient.fetchHistoryGalleries())) + } case .fetchGalleriesDone(let galleries): state.loadingState = .idle diff --git a/EhPanda/View/Home/History/HistoryView.swift b/EhPanda/View/Home/History/HistoryView.swift index 59e409bc..9e49b854 100644 --- a/EhPanda/View/Home/History/HistoryView.swift +++ b/EhPanda/View/Home/History/HistoryView.swift @@ -21,7 +21,7 @@ struct HistoryView: View { user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.user = user _setting = setting self.blurRadius = blurRadius @@ -42,7 +42,7 @@ struct HistoryView: View { } ) .sheet( - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /HistoryReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in @@ -55,7 +55,7 @@ struct HistoryView: View { } .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } - .searchable(text: viewStore.binding(\.$keyword), prompt: L10n.Localizable.Searchable.Prompt.filter) + .searchable(text: viewStore.$keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) .onAppear { if viewStore.galleries.isEmpty { DispatchQueue.main.async { @@ -70,7 +70,7 @@ struct HistoryView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HistoryReducer.Route.detail) { route in + NavigationLink(unwrapping: viewStore.$route, case: /HistoryReducer.Route.detail) { route in DetailView( store: store.scope(state: \.detailState, action: HistoryReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, @@ -89,7 +89,7 @@ struct HistoryView: View { .disabled(viewStore.loadingState != .idle || viewStore.galleries.isEmpty) .confirmationDialog( message: L10n.Localizable.ConfirmationDialog.Title.clear, - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /HistoryReducer.Route.clearHistory ) { Button(L10n.Localizable.ConfirmationDialog.Button.clear, role: .destructive) { @@ -106,7 +106,7 @@ struct HistoryView_Previews: PreviewProvider { HistoryView( store: .init( initialState: .init(), - reducer: HistoryReducer() + reducer: HistoryReducer.init ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Home/HomeReducer.swift b/EhPanda/View/Home/HomeReducer.swift index 0089bd72..bc84e8e6 100644 --- a/EhPanda/View/Home/HomeReducer.swift +++ b/EhPanda/View/Home/HomeReducer.swift @@ -92,6 +92,7 @@ struct HomeReducer: ReducerProtocol { @Dependency(\.databaseClient) private var databaseClient @Dependency(\.libraryClient) private var libraryClient + @Dependency(\.mainQueue) private var mainQueue var body: some ReducerProtocol { BindingReducer() @@ -99,22 +100,23 @@ struct HomeReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none + return state.route == nil ? .send(.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() + return .run { send in + try await mainQueue.sleep(for: .milliseconds(300)) + await send(.setAllowsCardHitTesting(true)) + } case .binding: return .none case .setNavigation(let route): state.route = route - return route == nil ? .init(value: .clearSubStates) : .none + return route == nil ? .send(.clearSubStates) : .none case .clearSubStates: state.frontpageState = .init() @@ -124,11 +126,11 @@ struct HomeReducer: ReducerProtocol { 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)) + .send(.frontpage(.teardown)), + .send(.toplists(.teardown)), + .send(.popular(.teardown)), + .send(.watched(.teardown)), + .send(.detail(.teardown)) ) case .setAllowsCardHitTesting(let isAllowed): @@ -137,24 +139,26 @@ struct HomeReducer: ReducerProtocol { case .fetchAllGalleries: return .merge( - .init(value: .fetchPopularGalleries), - .init(value: .fetchFrontpageGalleries), - .init(value: .fetchAllToplistsGalleries) + .send(.fetchPopularGalleries), + .send(.fetchFrontpageGalleries), + .send(.fetchAllToplistsGalleries) ) case .fetchAllToplistsGalleries: return .merge( ToplistsType.allCases.map({ Action.fetchToplistsGalleries($0.categoryIndex) }) - .map(EffectTask.init) + .map(Effect.send) ) 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) + return .run { send in + let filter = await databaseClient.fetchFilter(range: .global) + let result = await PopularGalleriesRequest(filter: filter).process() + await send(.fetchPopularGalleriesDone(result)) + } case .fetchPopularGalleriesDone(let result): state.popularLoadingState = .idle @@ -165,7 +169,7 @@ struct HomeReducer: ReducerProtocol { return .none } state.setPopularGalleries(galleries) - return databaseClient.cacheGalleries(galleries).fireAndForget() + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.popularLoadingState = .failed(error) } @@ -174,9 +178,11 @@ struct HomeReducer: ReducerProtocol { 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) + return .run { send in + let filter = await databaseClient.fetchFilter(range: .global) + let result = await FrontpageGalleriesRequest(filter: filter).process() + await send(.fetchFrontpageGalleriesDone(result)) + } case .fetchFrontpageGalleriesDone(let result): state.frontpageLoadingState = .idle @@ -187,7 +193,7 @@ struct HomeReducer: ReducerProtocol { return .none } state.setFrontpageGalleries(galleries) - return databaseClient.cacheGalleries(galleries).fireAndForget() + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.frontpageLoadingState = .failed(error) } @@ -208,7 +214,7 @@ struct HomeReducer: ReducerProtocol { return .none } state.toplistsGalleries[index] = galleries - return databaseClient.cacheGalleries(galleries).fireAndForget() + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.toplistsLoadingState[index] = .failed(error) } @@ -216,8 +222,9 @@ struct HomeReducer: ReducerProtocol { 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) }) + return .run { send in + await send(.analyzeImageColorsDone(gid, libraryClient.analyzeImageColors(result.image))) + } case .analyzeImageColorsDone(let gid, let colors): if let colors = colors { diff --git a/EhPanda/View/Home/HomeView.swift b/EhPanda/View/Home/HomeView.swift index 922ee525..e91a9026 100644 --- a/EhPanda/View/Home/HomeView.swift +++ b/EhPanda/View/Home/HomeView.swift @@ -24,7 +24,7 @@ struct HomeView: View { user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.user = user _setting = setting self.blurRadius = blurRadius @@ -40,7 +40,7 @@ struct HomeView: View { if !viewStore.popularGalleries.isEmpty { CardSlideSection( galleries: viewStore.popularGalleries, - pageIndex: viewStore.binding(\.$cardPageIndex), + pageIndex: viewStore.$cardPageIndex, currentID: viewStore.currentCardID, colors: viewStore.cardColors, navigateAction: navigateTo(gid:), @@ -88,7 +88,7 @@ struct HomeView: View { .zIndex(1) } .sheet( - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /HomeReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in @@ -136,7 +136,7 @@ private extension HomeView { sectionLink } var detailViewLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HomeReducer.Route.detail) { route in + NavigationLink(unwrapping: viewStore.$route, case: /HomeReducer.Route.detail) { route in DetailView( store: store.scope(state: \.detailState, action: HomeReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, @@ -145,7 +145,7 @@ private extension HomeView { } } var miscGridLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HomeReducer.Route.misc) { route in + NavigationLink(unwrapping: viewStore.$route, case: /HomeReducer.Route.misc) { route in switch route.wrappedValue { case .popular: PopularView( @@ -166,7 +166,7 @@ private extension HomeView { } } var sectionLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /HomeReducer.Route.section) { route in + NavigationLink(unwrapping: viewStore.$route, case: /HomeReducer.Route.section) { route in switch route.wrappedValue { case .frontpage: FrontpageView( @@ -521,7 +521,7 @@ struct HomeView_Previews: PreviewProvider { HomeView( store: .init( initialState: .init(), - reducer: HomeReducer() + reducer: HomeReducer.init ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Home/Popular/PopularReducer.swift b/EhPanda/View/Home/Popular/PopularReducer.swift index a6141703..0911bd5e 100644 --- a/EhPanda/View/Home/Popular/PopularReducer.swift +++ b/EhPanda/View/Home/Popular/PopularReducer.swift @@ -58,19 +58,19 @@ struct PopularReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none + return state.route == nil ? .send(.clearSubStates) : .none case .binding: return .none case .setNavigation(let route): state.route = route - return route == nil ? .init(value: .clearSubStates) : .none + return route == nil ? .send(.clearSubStates) : .none case .clearSubStates: state.detailState = .init() state.filtersState = .init() - return .init(value: .detail(.teardown)) + return .send(.detail(.teardown)) case .teardown: return .cancel(id: CancelID.fetchGalleries) @@ -78,9 +78,12 @@ struct PopularReducer: ReducerProtocol { 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) + return .run { send in + let filter = await databaseClient.fetchFilter(range: .global) + let result = await PopularGalleriesRequest(filter: filter).process() + await send(.fetchGalleriesDone(result)) + } + .cancellable(id: CancelID.fetchGalleries) case .fetchGalleriesDone(let result): state.loadingState = .idle @@ -91,7 +94,7 @@ struct PopularReducer: ReducerProtocol { return .none } state.galleries = galleries - return databaseClient.cacheGalleries(galleries).fireAndForget() + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.loadingState = .failed(error) } diff --git a/EhPanda/View/Home/Popular/PopularView.swift b/EhPanda/View/Home/Popular/PopularView.swift index 77a85236..3fa3c749 100644 --- a/EhPanda/View/Home/Popular/PopularView.swift +++ b/EhPanda/View/Home/Popular/PopularView.swift @@ -21,7 +21,7 @@ struct PopularView: View { user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.user = user _setting = setting self.blurRadius = blurRadius @@ -41,7 +41,7 @@ struct PopularView: View { } ) .sheet( - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /PopularReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in @@ -54,11 +54,11 @@ struct PopularView: View { } .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /PopularReducer.Route.filters) { _ in + .sheet(unwrapping: viewStore.$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) + .searchable(text: viewStore.$keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) .onAppear { if viewStore.galleries.isEmpty { DispatchQueue.main.async { @@ -73,7 +73,7 @@ struct PopularView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /PopularReducer.Route.detail) { route in + NavigationLink(unwrapping: viewStore.$route, case: /PopularReducer.Route.detail) { route in DetailView( store: store.scope(state: \.detailState, action: PopularReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, @@ -97,7 +97,7 @@ struct PopularView_Previews: PreviewProvider { PopularView( store: .init( initialState: .init(), - reducer: PopularReducer() + reducer: PopularReducer.init ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Home/Toplists/ToplistsReducer.swift b/EhPanda/View/Home/Toplists/ToplistsReducer.swift index 6497de5b..74e0e5f3 100644 --- a/EhPanda/View/Home/Toplists/ToplistsReducer.swift +++ b/EhPanda/View/Home/Toplists/ToplistsReducer.swift @@ -91,7 +91,7 @@ struct ToplistsReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none + return state.route == nil ? .send(.clearSubStates) : .none case .binding(\.$jumpPageAlertPresented): if !state.jumpPageAlertPresented { @@ -104,28 +104,28 @@ struct ToplistsReducer: ReducerProtocol { case .setNavigation(let route): state.route = route - return route == nil ? .init(value: .clearSubStates) : .none + return route == nil ? .send(.clearSubStates) : .none case .setToplistsType(let type): state.type = type guard state.galleries?.isEmpty != false else { return .none } - return .init(value: Action.fetchGalleries()) + return .send(.fetchGalleries()) case .clearSubStates: state.detailState = .init() - return .init(value: .detail(.teardown)) + return .send(.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 .run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) }) } - return .init(value: .fetchGalleries(index - 1)) + return .send(.fetchGalleries(index - 1)) case .presentJumpPageAlert: state.jumpPageAlertPresented = true - return .fireAndForget({ hapticsClient.generateFeedback(.light) }) + return .run(operation: { _ in hapticsClient.generateFeedback(.light) }) case .setJumpPageAlertFocused(let isFocused): state.jumpPageAlertFocused = isFocused @@ -153,11 +153,11 @@ struct ToplistsReducer: ReducerProtocol { guard !galleries.isEmpty else { state.rawLoadingState[type] = .failed(.notFound) guard pageNumber.hasNextPage() else { return .none } - return .init(value: .fetchMoreGalleries) + return .send(.fetchMoreGalleries) } state.rawPageNumber[type] = pageNumber state.rawGalleries[type] = galleries - return databaseClient.cacheGalleries(galleries).fireAndForget() + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.rawLoadingState[type] = .failed(error) } @@ -181,11 +181,11 @@ struct ToplistsReducer: ReducerProtocol { state.rawPageNumber[type] = pageNumber state.insertGalleries(type: type, galleries: galleries) - var effects: [EffectTask] = [ - databaseClient.cacheGalleries(galleries).fireAndForget() + var effects: [Effect] = [ + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) ] if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.init(value: .fetchMoreGalleries)) + effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { state.rawLoadingState[type] = .idle } diff --git a/EhPanda/View/Home/Toplists/ToplistsView.swift b/EhPanda/View/Home/Toplists/ToplistsView.swift index e68d917a..362c3828 100644 --- a/EhPanda/View/Home/Toplists/ToplistsView.swift +++ b/EhPanda/View/Home/Toplists/ToplistsView.swift @@ -21,7 +21,7 @@ struct ToplistsView: View { user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.user = user _setting = setting self.blurRadius = blurRadius @@ -47,7 +47,7 @@ struct ToplistsView: View { } ) .sheet( - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /ToplistsReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in @@ -61,13 +61,13 @@ struct ToplistsView: View { .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } .jumpPageAlert( - index: viewStore.binding(\.$jumpPageIndex), - isPresented: viewStore.binding(\.$jumpPageAlertPresented), - isFocused: viewStore.binding(\.$jumpPageAlertFocused), + index: viewStore.$jumpPageIndex, + isPresented: viewStore.$jumpPageAlertPresented, + isFocused: viewStore.$jumpPageAlertFocused, pageNumber: viewStore.pageNumber ?? .init(), jumpAction: { viewStore.send(.performJumpPage) } ) - .searchable(text: viewStore.binding(\.$keyword), prompt: L10n.Localizable.Searchable.Prompt.filter) + .searchable(text: viewStore.$keyword, prompt: L10n.Localizable.Searchable.Prompt.filter) .navigationBarBackButtonHidden(viewStore.jumpPageAlertPresented) .animation(.default, value: viewStore.jumpPageAlertPresented) .onAppear { @@ -84,7 +84,7 @@ struct ToplistsView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /ToplistsReducer.Route.detail) { route in + NavigationLink(unwrapping: viewStore.$route, case: /ToplistsReducer.Route.detail) { route in DetailView( store: store.scope(state: \.detailState, action: ToplistsReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, @@ -155,7 +155,7 @@ struct ToplistsView_Previews: PreviewProvider { ToplistsView( store: .init( initialState: .init(), - reducer: ToplistsReducer() + reducer: ToplistsReducer.init ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Home/Watched/WatchedReducer.swift b/EhPanda/View/Home/Watched/WatchedReducer.swift index 6f583d64..aab04c3c 100644 --- a/EhPanda/View/Home/Watched/WatchedReducer.swift +++ b/EhPanda/View/Home/Watched/WatchedReducer.swift @@ -70,22 +70,22 @@ struct WatchedReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none + return state.route == nil ? .send(.clearSubStates) : .none case .binding: return .none case .setNavigation(let route): state.route = route - return route == nil ? .init(value: .clearSubStates) : .none + return route == nil ? .send(.clearSubStates) : .none case .clearSubStates: state.detailState = .init() state.filtersState = .init() state.quickSearchState = .init() return .merge( - .init(value: .detail(.teardown)), - .init(value: .quickSearch(.teardown)) + .send(.detail(.teardown)), + .send(.quickSearch(.teardown)) ) case .onNotLoginViewButtonTapped: @@ -101,9 +101,13 @@ struct WatchedReducer: ReducerProtocol { } 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) + let keyword = state.keyword + return .run { send in + let filter = await databaseClient.fetchFilter(range: .watched) + let result = await WatchedGalleriesRequest(filter: filter, keyword: keyword).process() + await send(.fetchGalleriesDone(result)) + } + .cancellable(id: CancelID.fetchGalleries) case .fetchGalleriesDone(let result): state.loadingState = .idle @@ -112,11 +116,11 @@ struct WatchedReducer: ReducerProtocol { guard !galleries.isEmpty else { state.loadingState = .failed(.notFound) guard pageNumber.hasNextPage() else { return .none } - return .init(value: .fetchMoreGalleries) + return .send(.fetchMoreGalleries) } state.pageNumber = pageNumber state.galleries = galleries - return databaseClient.cacheGalleries(galleries).fireAndForget() + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.loadingState = .failed(error) } @@ -129,10 +133,16 @@ struct WatchedReducer: ReducerProtocol { 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) + let keyword = state.keyword + return .run { send in + let filter = await databaseClient.fetchFilter(range: .watched) + let result = await MoreWatchedGalleriesRequest( + filter: filter, lastID: lastID, keyword: keyword + ) + .process() + await send(.fetchMoreGalleriesDone(result)) + } + .cancellable(id: CancelID.fetchMoreGalleries) case .fetchMoreGalleriesDone(let result): state.footerLoadingState = .idle @@ -141,11 +151,11 @@ struct WatchedReducer: ReducerProtocol { state.pageNumber = pageNumber state.insertGalleries(galleries) - var effects: [EffectTask] = [ - databaseClient.cacheGalleries(galleries).fireAndForget() + var effects: [Effect] = [ + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) ] if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.init(value: .fetchMoreGalleries)) + effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { state.loadingState = .idle } diff --git a/EhPanda/View/Home/Watched/WatchedView.swift b/EhPanda/View/Home/Watched/WatchedView.swift index 1612608b..ce34916c 100644 --- a/EhPanda/View/Home/Watched/WatchedView.swift +++ b/EhPanda/View/Home/Watched/WatchedView.swift @@ -21,7 +21,7 @@ struct WatchedView: View { user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.user = user _setting = setting self.blurRadius = blurRadius @@ -49,7 +49,7 @@ struct WatchedView: View { } } .sheet( - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /WatchedReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in @@ -62,7 +62,7 @@ struct WatchedView: View { } .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /WatchedReducer.Route.quickSearch) { _ in + .sheet(unwrapping: viewStore.$route, case: /WatchedReducer.Route.quickSearch) { _ in QuickSearchView( store: store.scope(state: \.quickSearchState, action: WatchedReducer.Action.quickSearch) ) { keyword in @@ -72,14 +72,14 @@ struct WatchedView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /WatchedReducer.Route.filters) { _ in + .sheet(unwrapping: viewStore.$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.$keyword) .searchSuggestions { TagSuggestionView( - keyword: viewStore.binding(\.$keyword), translations: tagTranslator.translations, + keyword: viewStore.$keyword, translations: tagTranslator.translations, showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion ) } @@ -100,7 +100,7 @@ struct WatchedView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /WatchedReducer.Route.detail) { route in + NavigationLink(unwrapping: viewStore.$route, case: /WatchedReducer.Route.detail) { route in DetailView( store: store.scope(state: \.detailState, action: WatchedReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, @@ -129,7 +129,7 @@ struct WatchedView_Previews: PreviewProvider { WatchedView( store: .init( initialState: .init(), - reducer: WatchedReducer() + reducer: WatchedReducer.init ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Migration/MigrationReducer.swift b/EhPanda/View/Migration/MigrationReducer.swift index 3488ed04..08dfe2bb 100644 --- a/EhPanda/View/Migration/MigrationReducer.swift +++ b/EhPanda/View/Migration/MigrationReducer.swift @@ -30,6 +30,7 @@ struct MigrationReducer: ReducerProtocol { } @Dependency(\.databaseClient) private var databaseClient + @Dependency(\.mainQueue) private var mainQueue var body: some ReducerProtocol { BindingReducer() @@ -47,7 +48,9 @@ struct MigrationReducer: ReducerProtocol { return .none case .prepareDatabase: - return databaseClient.prepareDatabase().map(Action.prepareDatabaseDone) + return .run { send in + await send(.prepareDatabaseDone(databaseClient.prepareDatabase())) + } case .prepareDatabaseDone(let appError): if let appError { @@ -55,14 +58,15 @@ struct MigrationReducer: ReducerProtocol { return .none } else { state.databaseState = .idle - return .init(value: .onDatabasePreparationSuccess) + return .send(.onDatabasePreparationSuccess) } case .dropDatabase: state.databaseState = .loading - return databaseClient.dropDatabase() - .delay(for: .milliseconds(500), scheduler: DispatchQueue.main) - .eraseToEffect().map(Action.dropDatabaseDone) + return .run { send in + try await mainQueue.sleep(for: .milliseconds(500)) + await send(.dropDatabaseDone(databaseClient.dropDatabase())) + } case .dropDatabaseDone(let appError): if let appError { @@ -70,7 +74,7 @@ struct MigrationReducer: ReducerProtocol { return .none } else { state.databaseState = .idle - return .init(value: .onDatabasePreparationSuccess) + return .send(.onDatabasePreparationSuccess) } } } diff --git a/EhPanda/View/Migration/MigrationView.swift b/EhPanda/View/Migration/MigrationView.swift index 5a2984f1..d103c862 100644 --- a/EhPanda/View/Migration/MigrationView.swift +++ b/EhPanda/View/Migration/MigrationView.swift @@ -19,7 +19,7 @@ struct MigrationView: View { init(store: StoreOf) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) } var body: some View { @@ -36,7 +36,7 @@ struct MigrationView: View { } .confirmationDialog( message: L10n.Localizable.ConfirmationDialog.Title.dropDatabase, - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /MigrationReducer.Route.dropDialog ) { Button(L10n.Localizable.ConfirmationDialog.Button.dropDatabase, role: .destructive) { @@ -56,7 +56,7 @@ struct MigrationView_Previews: PreviewProvider { MigrationView( store: .init( initialState: .init(), - reducer: MigrationReducer() + reducer: MigrationReducer.init ) ) } diff --git a/EhPanda/View/Reading/ReadingReducer.swift b/EhPanda/View/Reading/ReadingReducer.swift index 96b5560b..5349977b 100644 --- a/EhPanda/View/Reading/ReadingReducer.swift +++ b/EhPanda/View/Reading/ReadingReducer.swift @@ -190,7 +190,7 @@ struct ReadingReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$showsSliderPreview): - return .fireAndForget({ hapticsClient.generateFeedback(.soft) }) + return .run(operation: { _ in hapticsClient.generateFeedback(.soft) }) case .binding: return .none @@ -204,24 +204,25 @@ struct ReadingReducer: ReducerProtocol { 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 .run { _ in + if isPortrait { + // TODO: Fix me later + await appDelegateClient.setPortraitOrientationMask() + appDelegateClient.setPortraitOrientation() + } else { + appDelegateClient.setAllOrientationMask() + } } - return .merge(effects) case .onPerformDismiss: - return .fireAndForget({ hapticsClient.generateFeedback(.light) }) + return .run(operation: { _ in hapticsClient.generateFeedback(.light) }) case .onAppear(let gid, let enablesLandscape): - var effects: [EffectTask] = [ - .init(value: .fetchDatabaseInfos(gid)) + var effects: [Effect] = [ + .send(.fetchDatabaseInfos(gid)) ] if enablesLandscape { - effects.append(.init(value: .setOrientationPortrait(false))) + effects.append(.send(.setOrientationPortrait(false))) } return .merge(effects) @@ -247,7 +248,8 @@ struct ReadingReducer: ReducerProtocol { state.mpvImageKeys = .init() state.mpvSkipServerIdentifiers = .init() state.forceRefreshID = .init() - return databaseClient.removeImageURLs(gid: state.gallery.id).fireAndForget() + let gid = state.gallery.id + return .run(operation: { _ in await databaseClient.removeImageURLs(gid: gid) }) case .retryAllFailedWebImages: state.imageURLLoadingStates.forEach { (index, loadingState) in @@ -263,21 +265,20 @@ struct ReadingReducer: ReducerProtocol { return .none case .copyImage(let imageURL): - return .init(value: .fetchImage(.copy(imageURL.isGIF), imageURL)) + return .send(.fetchImage(.copy(imageURL.isGIF), imageURL)) case .saveImage(let imageURL): - return .init(value: .fetchImage(.save(imageURL.isGIF), imageURL)) + return .send(.fetchImage(.save(imageURL.isGIF), imageURL)) case .saveImageDone(let isSucceeded): state.hudConfig = isSucceeded ? .savedToPhotoLibrary : .error - return .init(value: .setNavigation(.hud)) + return .send(.setNavigation(.hud)) case .shareImage(let imageURL): - return .init(value: .fetchImage(.share(imageURL.isGIF), imageURL)) + return .send(.fetchImage(.share(imageURL.isGIF), imageURL)) case .fetchImage(let action, let imageURL): - return imageClient.fetchImage(url: imageURL) - .map({ Action.fetchImageDone(action, $0) }) + return .run(operation: { await $0(.fetchImageDone(action, imageClient.fetchImage(url: imageURL))) }) .cancellable(id: CancelID.fetchImage) case .fetchImageDone(let action, let result): @@ -285,57 +286,70 @@ struct ReadingReducer: ReducerProtocol { switch action { case .copy(let isAnimated): state.hudConfig = .copiedToClipboardSucceeded - return .merge( - .init(value: .setNavigation(.hud)), - clipboardClient.saveImage(image, isAnimated).fireAndForget() - ) + return .run { send in + await send(.setNavigation(.hud)) + clipboardClient.saveImage(image, isAnimated) + } case .save(let isAnimated): - return imageClient - .saveImageToPhotoLibrary(image, isAnimated).map(Action.saveImageDone) + return .run { send in + await send(.saveImageDone(imageClient.saveImageToPhotoLibrary(image, isAnimated))) + } case .share(let isAnimated): if isAnimated, let data = image.kf.data(format: .GIF) { - return .init(value: .setNavigation(.share(.data(data)))) + return .send(.setNavigation(.share(.data(data)))) } else { - return .init(value: .setNavigation(.share(.image(image)))) + return .send(.setNavigation(.share(.image(image)))) } } } else { state.hudConfig = .error - return .init(value: .setNavigation(.hud)) + return .send(.setNavigation(.hud)) } case .syncReadingProgress(let progress): - return databaseClient - .updateReadingProgress(gid: state.gallery.id, progress: progress).fireAndForget() + let gid = state.gallery.id + return .run(operation: { _ in await databaseClient.cacheReadingProgress(gid: gid, progress: progress) }) case .syncPreviewURLs(let previewURLs): - return databaseClient - .updatePreviewURLs(gid: state.gallery.id, previewURLs: previewURLs).fireAndForget() + let gid = state.gallery.id + return .run { _ in + await databaseClient.cachePreviewURLs(gid: gid, previewURLs: previewURLs) + } case .syncThumbnailURLs(let thumbnailURLs): - return databaseClient - .updateThumbnailURLs(gid: state.gallery.id, thumbnailURLs: thumbnailURLs).fireAndForget() + let gid = state.gallery.id + return .run { _ in + await databaseClient.cacheThumbnailURLs(gid: gid, thumbnailURLs: thumbnailURLs) + } case .syncImageURLs(let imageURLs, let originalImageURLs): - return databaseClient - .updateImageURLs(gid: state.gallery.id, imageURLs: imageURLs, originalImageURLs: originalImageURLs) - .fireAndForget() + let gid = state.gallery.id + return .run { _ in + await databaseClient.cacheImageURLs( + gid: gid, imageURLs: imageURLs, originalImageURLs: originalImageURLs + ) + } case .teardown: - var effects: [EffectTask] = [ + var effects: [Effect] = [ .cancel(ids: CancelID.allCases) ] if !deviceClient.isPad() { - effects.append(.init(value: .setOrientationPortrait(true))) + effects.append(.send(.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) + // TODO: Fix me later +// guard let gallery = databaseClient.fetchGallery(gid: gid) else { return .none } +// state.gallery = gallery +// state.galleryDetail = databaseClient.fetchGalleryDetail(gid: state.gallery.id) + let gid = state.gallery.id + return .run { send in + let galleryState = await databaseClient.fetchGalleryState(gid: gid) + await send(.fetchDatabaseInfosDone(galleryState)) + } + .cancellable(id: CancelID.fetchDatabaseInfos) case .fetchDatabaseInfosDone(let galleryState): if let previewConfig = galleryState.previewConfig { @@ -367,7 +381,7 @@ struct ReadingReducer: ReducerProtocol { } state.previewLoadingStates[index] = .idle state.updatePreviewURLs(previewURLs) - return .init(value: .syncPreviewURLs(previewURLs)) + return .send(.syncPreviewURLs(previewURLs)) case .failure(let error): state.previewLoadingStates[index] = .failed(error) } @@ -375,16 +389,16 @@ struct ReadingReducer: ReducerProtocol { case .fetchImageURLs(let index): if state.mpvKey != nil { - return .init(value: .fetchMPVImageURL(index, false)) + return .send(.fetchMPVImageURL(index, false)) } else { - return .init(value: .fetchThumbnailURLs(index)) + return .send(.fetchThumbnailURLs(index)) } case .refetchImageURLs(let index): if state.mpvKey != nil { - return .init(value: .fetchMPVImageURL(index, true)) + return .send(.fetchMPVImageURL(index, true)) } else { - return .init(value: .refetchNormalImageURLs(index)) + return .send(.refetchNormalImageURLs(index)) } case .prefetchImages(let index, let prefetchLimit): @@ -406,7 +420,7 @@ struct ReadingReducer: ReducerProtocol { } var prefetchImageURLs = [URL]() var fetchImageURLIndices = [Int]() - var effects = [EffectTask]() + var effects = [Effect]() let previousUpperBound = max(index - 2, 1) let previousLowerBound = max(previousUpperBound - prefetchLimit / 2, 1) if previousUpperBound - previousLowerBound > 0 { @@ -420,9 +434,11 @@ struct ReadingReducer: ReducerProtocol { fetchImageURLIndices += getFetchImageURLIndices(range: nextLowerBound...nextUpperBound) } fetchImageURLIndices.forEach { - effects.append(.init(value: .fetchImageURLs($0))) + effects.append(.send(.fetchImageURLs($0))) } - effects.append(imageClient.prefetchImages(prefetchImageURLs).fireAndForget()) + effects.append( + .run(operation: { [prefetchImageURLs] _ in imageClient.prefetchImages(prefetchImageURLs) }) + ) return .merge(effects) case .fetchThumbnailURLs(let index): @@ -448,12 +464,12 @@ struct ReadingReducer: ReducerProtocol { return .none } if let url = thumbnailURLs[index], urlClient.checkIfMPVURL(url) { - return .init(value: .fetchMPVKeys(index, url)) + return .send(.fetchMPVKeys(index, url)) } else { state.updateThumbnailURLs(thumbnailURLs) return .merge( - .init(value: .syncThumbnailURLs(thumbnailURLs)), - .init(value: .fetchNormalImageURLs(index, thumbnailURLs)) + .send(.syncThumbnailURLs(thumbnailURLs)), + .send(.fetchNormalImageURLs(index, thumbnailURLs)) ) } case .failure(let error): @@ -482,7 +498,7 @@ struct ReadingReducer: ReducerProtocol { state.imageURLLoadingStates[$0] = .idle } state.updateImageURLs(imageURLs, originalImageURLs) - return .init(value: .syncImageURLs(imageURLs, originalImageURLs)) + return .send(.syncImageURLs(imageURLs, originalImageURLs)) case .failure(let error): batchRange.forEach { state.imageURLLoadingStates[$0] = .failed(error) @@ -509,9 +525,9 @@ struct ReadingReducer: ReducerProtocol { case .refetchNormalImageURLsDone(let index, let result): switch result { case .success(let (imageURLs, response)): - var effects = [EffectTask]() + var effects = [Effect]() if let response = response { - effects.append(cookieClient.setSkipServer(response: response).fireAndForget()) + effects.append(.run(operation: { _ in cookieClient.setSkipServer(response: response) })) } guard !imageURLs.isEmpty else { state.imageURLLoadingStates[index] = .failed(.notFound) @@ -519,7 +535,7 @@ struct ReadingReducer: ReducerProtocol { } state.imageURLLoadingStates[index] = .idle state.updateImageURLs(imageURLs, [:]) - effects.append(.init(value: .syncImageURLs(imageURLs, [:]))) + effects.append(.send(.syncImageURLs(imageURLs, [:]))) return .merge(effects) case .failure(let error): state.imageURLLoadingStates[index] = .failed(error) @@ -548,7 +564,7 @@ struct ReadingReducer: ReducerProtocol { state.mpvImageKeys = mpvImageKeys return .merge( Array(1...min(3, max(1, pageCount))).map { - .init(value: .fetchMPVImageURL($0, false)) + .send(.fetchMPVImageURL($0, false)) } ) case .failure(let error): @@ -582,7 +598,7 @@ struct ReadingReducer: ReducerProtocol { state.imageURLLoadingStates[index] = .idle state.mpvSkipServerIdentifiers[index] = skipServerIdentifier state.updateImageURLs(imageURLs, originalImageURLs) - return .init(value: .syncImageURLs(imageURLs, originalImageURLs)) + return .send(.syncImageURLs(imageURLs, originalImageURLs)) case .failure(let error): state.imageURLLoadingStates[index] = .failed(error) } diff --git a/EhPanda/View/Reading/ReadingView.swift b/EhPanda/View/Reading/ReadingView.swift index cb361eca..72578753 100644 --- a/EhPanda/View/Reading/ReadingView.swift +++ b/EhPanda/View/Reading/ReadingView.swift @@ -30,7 +30,7 @@ struct ReadingView: View { gid: String, setting: Binding, blurRadius: Double ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.gid = gid _setting = setting self.blurRadius = blurRadius @@ -67,8 +67,8 @@ struct ReadingView: View { .id(viewStore.databaseLoadingState) .id(viewStore.forceRefreshID) ControlPanel( - showsPanel: viewStore.binding(\.$showsPanel), - showsSliderPreview: viewStore.binding(\.$showsSliderPreview), + showsPanel: viewStore.$showsPanel, + showsSliderPreview: viewStore.$showsSliderPreview, sliderValue: $pageHandler.sliderValue, setting: $setting, enablesLiveText: $liveTextHandler.enablesLiveText, autoPlayPolicy: .init(get: { autoPlayHandler.policy }, set: setAutoPlayPolocy), @@ -81,7 +81,7 @@ struct ReadingView: View { fetchPreviewURLsAction: { viewStore.send(.fetchPreviewURLs($0)) } ) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /ReadingReducer.Route.readingSetting) { _ in + .sheet(unwrapping: viewStore.$route, case: /ReadingReducer.Route.readingSetting) { _ in NavigationView { ReadingSettingView( readingDirection: $setting.readingDirection, @@ -106,13 +106,13 @@ struct ReadingView: View { .accentColor(setting.accentColor).tint(setting.accentColor) .autoBlur(radius: blurRadius).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /ReadingReducer.Route.share) { route in + .sheet(unwrapping: viewStore.$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), + unwrapping: viewStore.$route, case: /ReadingReducer.Route.hud ) @@ -600,7 +600,7 @@ struct ReadingView_Previews: PreviewProvider { ReadingView( store: .init( initialState: .init(gallery: .empty), - reducer: ReadingReducer() + reducer: ReadingReducer.init ), gid: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Search/SearchReducer.swift b/EhPanda/View/Search/SearchReducer.swift index f4ea5499..b4fca0a4 100644 --- a/EhPanda/View/Search/SearchReducer.swift +++ b/EhPanda/View/Search/SearchReducer.swift @@ -70,7 +70,7 @@ struct SearchReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none + return state.route == nil ? .send(.clearSubStates) : .none case .binding(\.$keyword): if !state.keyword.isEmpty { @@ -83,15 +83,15 @@ struct SearchReducer: ReducerProtocol { case .setNavigation(let route): state.route = route - return route == nil ? .init(value: .clearSubStates) : .none + return route == nil ? .send(.clearSubStates) : .none case .clearSubStates: state.detailState = .init() state.filtersState = .init() state.quickSearchState = .init() return .merge( - .init(value: .detail(.teardown)), - .init(value: .quickSearch(.teardown)) + .send(.detail(.teardown)), + .send(.quickSearch(.teardown)) ) case .teardown: @@ -105,10 +105,13 @@ struct SearchReducer: ReducerProtocol { } 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) + let lastKeyword = state.lastKeyword + return .run { send in + let filter = await databaseClient.fetchFilter(range: .search) + let result = await SearchGalleriesRequest(keyword: lastKeyword, filter: filter).process() + await send(.fetchGalleriesDone(result)) + } + .cancellable(id: CancelID.fetchGalleries) case .fetchGalleriesDone(let result): state.loadingState = .idle @@ -117,11 +120,11 @@ struct SearchReducer: ReducerProtocol { guard !galleries.isEmpty else { state.loadingState = .failed(.notFound) guard pageNumber.hasNextPage() else { return .none } - return .init(value: .fetchMoreGalleries) + return .send(.fetchMoreGalleries) } state.pageNumber = pageNumber state.galleries = galleries - return databaseClient.cacheGalleries(galleries).fireAndForget() + return .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) case .failure(let error): state.loadingState = .failed(error) } @@ -134,10 +137,16 @@ struct SearchReducer: ReducerProtocol { 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) + let lastKeyword = state.lastKeyword + return .run { send in + let filter = await databaseClient.fetchFilter(range: .search) + let result = await MoreSearchGalleriesRequest( + keyword: lastKeyword, filter: filter, lastID: lastID + ) + .process() + await send(.fetchMoreGalleriesDone(result)) + } + .cancellable(id: CancelID.fetchMoreGalleries) case .fetchMoreGalleriesDone(let result): state.footerLoadingState = .idle @@ -146,11 +155,11 @@ struct SearchReducer: ReducerProtocol { state.pageNumber = pageNumber state.insertGalleries(galleries) - var effects: [EffectTask] = [ - databaseClient.cacheGalleries(galleries).fireAndForget() + var effects: [Effect] = [ + .run(operation: { _ in await databaseClient.cacheGalleries(galleries) }) ] if galleries.isEmpty, pageNumber.hasNextPage() { - effects.append(.init(value: .fetchMoreGalleries)) + effects.append(.send(.fetchMoreGalleries)) } else if !galleries.isEmpty { state.loadingState = .idle } diff --git a/EhPanda/View/Search/SearchRootReducer.swift b/EhPanda/View/Search/SearchRootReducer.swift index a3e23634..c19a36ff 100644 --- a/EhPanda/View/Search/SearchRootReducer.swift +++ b/EhPanda/View/Search/SearchRootReducer.swift @@ -94,8 +94,8 @@ struct SearchRootReducer: ReducerProtocol { case .binding(\.$route): return state.route == nil ? .merge( - .init(value: .clearSubStates), - .init(value: .fetchDatabaseInfos) + .send(.clearSubStates), + .send(.fetchDatabaseInfos) ) : .none @@ -106,8 +106,8 @@ struct SearchRootReducer: ReducerProtocol { state.route = route return route == nil ? .merge( - .init(value: .clearSubStates), - .init(value: .fetchDatabaseInfos) + .send(.clearSubStates), + .send(.fetchDatabaseInfos) ) : .none @@ -121,16 +121,17 @@ struct SearchRootReducer: ReducerProtocol { state.filtersState = .init() state.quickSearchState = .init() return .merge( - .init(value: .search(.teardown)), - .init(value: .quickSearch(.teardown)), - .init(value: .detail(.teardown)) + .send(.search(.teardown)), + .send(.quickSearch(.teardown)), + .send(.detail(.teardown)) ) case .syncHistoryKeywords: - return databaseClient.updateHistoryKeywords(state.historyKeywords).fireAndForget() + let keywords = state.historyKeywords + return .run(operation: { _ in await databaseClient.cacheHistoryKeywords(keywords) }) case .fetchDatabaseInfos: - return databaseClient.fetchAppEnv().map(Action.fetchDatabaseInfosDone) + return .run(operation: { await $0(.fetchDatabaseInfosDone(databaseClient.fetchAppEnv())) }) case .fetchDatabaseInfosDone(let appEnv): state.historyKeywords = appEnv.historyKeywords @@ -139,14 +140,16 @@ struct SearchRootReducer: ReducerProtocol { case .appendHistoryKeyword(let keyword): state.appendHistoryKeywords([keyword]) - return .init(value: .syncHistoryKeywords) + return .send(.syncHistoryKeywords) case .removeHistoryKeyword(let keyword): state.removeHistoryKeyword(keyword) - return .init(value: .syncHistoryKeywords) + return .send(.syncHistoryKeywords) case .fetchHistoryGalleries: - return databaseClient.fetchHistoryGalleries(fetchLimit: 10).map(Action.fetchHistoryGalleriesDone) + return .run { send in + await send(.fetchHistoryGalleriesDone(databaseClient.fetchHistoryGalleries(fetchLimit: 10))) + } case .fetchHistoryGalleriesDone(let galleries): state.historyGalleries = Array(galleries.prefix(min(galleries.count, 10))) @@ -158,7 +161,7 @@ struct SearchRootReducer: ReducerProtocol { } else { state.appendHistoryKeywords([state.searchState.lastKeyword]) } - return .init(value: .syncHistoryKeywords) + return .send(.syncHistoryKeywords) case .search: return .none diff --git a/EhPanda/View/Search/SearchRootView.swift b/EhPanda/View/Search/SearchRootView.swift index 52e0a7f0..69abe14c 100644 --- a/EhPanda/View/Search/SearchRootView.swift +++ b/EhPanda/View/Search/SearchRootView.swift @@ -21,7 +21,7 @@ struct SearchRootView: View { user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.user = user _setting = setting self.blurRadius = blurRadius @@ -45,7 +45,7 @@ struct SearchRootView: View { ) } .sheet( - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /SearchRootReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in @@ -58,11 +58,11 @@ struct SearchRootView: View { } .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /SearchRootReducer.Route.filters) { _ in + .sheet(unwrapping: viewStore.$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: /SearchRootReducer.Route.quickSearch) { _ in + .sheet(unwrapping: viewStore.$route, case: /SearchRootReducer.Route.quickSearch) { _ in QuickSearchView( store: store.scope(state: \.quickSearchState, action: SearchRootReducer.Action.quickSearch) ) { keyword in @@ -75,10 +75,10 @@ struct SearchRootView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .searchable(text: viewStore.binding(\.$keyword)) + .searchable(text: viewStore.$keyword) .searchSuggestions { TagSuggestionView( - keyword: viewStore.binding(\.$keyword), translations: tagTranslator.translations, + keyword: viewStore.$keyword, translations: tagTranslator.translations, showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion ) } @@ -117,7 +117,7 @@ private extension SearchRootView { searchViewLink } var detailViewLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SearchRootReducer.Route.detail) { route in + NavigationLink(unwrapping: viewStore.$route, case: /SearchRootReducer.Route.detail) { route in DetailView( store: store.scope(state: \.detailState, action: SearchRootReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, @@ -126,7 +126,7 @@ private extension SearchRootView { } } var searchViewLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SearchRootReducer.Route.search) { _ in + NavigationLink(unwrapping: viewStore.$route, case: /SearchRootReducer.Route.search) { _ in SearchView( store: store.scope(state: \.searchState, action: SearchRootReducer.Action.search), keyword: viewStore.keyword, user: user, setting: $setting, @@ -406,7 +406,7 @@ struct SearchRootView_Previews: PreviewProvider { SearchRootView( store: .init( initialState: .init(), - reducer: SearchRootReducer() + reducer: SearchRootReducer.init ), user: .init(), setting: .constant(.init()), diff --git a/EhPanda/View/Search/SearchView.swift b/EhPanda/View/Search/SearchView.swift index 9a637652..72f3043b 100644 --- a/EhPanda/View/Search/SearchView.swift +++ b/EhPanda/View/Search/SearchView.swift @@ -22,7 +22,7 @@ struct SearchView: View { keyword: String, user: User, setting: Binding, blurRadius: Double, tagTranslator: TagTranslator ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.keyword = keyword self.user = user _setting = setting @@ -45,7 +45,7 @@ struct SearchView: View { } ) .sheet( - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /SearchReducer.Route.detail, isEnabled: DeviceUtil.isPad ) { route in @@ -58,7 +58,7 @@ struct SearchView: View { } .autoBlur(radius: blurRadius).environment(\.inSheet, true).navigationViewStyle(.stack) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /SearchReducer.Route.quickSearch) { _ in + .sheet(unwrapping: viewStore.$route, case: /SearchReducer.Route.quickSearch) { _ in QuickSearchView( store: store.scope(state: \.quickSearchState, action: SearchReducer.Action.quickSearch) ) { keyword in @@ -68,14 +68,14 @@ struct SearchView: View { .accentColor(setting.accentColor) .autoBlur(radius: blurRadius) } - .sheet(unwrapping: viewStore.binding(\.$route), case: /SearchReducer.Route.filters) { _ in + .sheet(unwrapping: viewStore.$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.$keyword) .searchSuggestions { TagSuggestionView( - keyword: viewStore.binding(\.$keyword), translations: tagTranslator.translations, + keyword: viewStore.$keyword, translations: tagTranslator.translations, showsImages: setting.showsImagesInTags, isEnabled: setting.showsTagsSearchSuggestion ) } @@ -96,7 +96,7 @@ struct SearchView: View { @ViewBuilder private var navigationLink: some View { if DeviceUtil.isPhone { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SearchReducer.Route.detail) { route in + NavigationLink(unwrapping: viewStore.$route, case: /SearchReducer.Route.detail) { route in DetailView( store: store.scope(state: \.detailState, action: SearchReducer.Action.detail), gid: route.wrappedValue, user: user, setting: $setting, @@ -124,7 +124,7 @@ struct SearchView_Previews: PreviewProvider { SearchView( store: .init( initialState: .init(), - reducer: SearchReducer() + reducer: SearchReducer.init ), keyword: .init(), user: .init(), diff --git a/EhPanda/View/Search/Support/QuickSearchReducer.swift b/EhPanda/View/Search/Support/QuickSearchReducer.swift index a1449c90..73c035c3 100644 --- a/EhPanda/View/Search/Support/QuickSearchReducer.swift +++ b/EhPanda/View/Search/Support/QuickSearchReducer.swift @@ -67,14 +67,14 @@ struct QuickSearchReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none + return state.route == nil ? .send(.clearSubStates) : .none case .binding: return .none case .setNavigation(let route): state.route = route - return route == nil ? .init(value: .clearSubStates) : .none + return route == nil ? .send(.clearSubStates) : .none case .clearSubStates: state.focusedField = nil @@ -82,7 +82,8 @@ struct QuickSearchReducer: ReducerProtocol { return .none case .syncQuickSearchWords: - return databaseClient.updateQuickSearchWords(state.quickSearchWords).fireAndForget() + let words = state.quickSearchWords + return .run(operation: { _ in await databaseClient.cacheQuickSearchWords(words) }) case .toggleListEditing: state.isListEditing.toggle() @@ -94,34 +95,33 @@ struct QuickSearchReducer: ReducerProtocol { case .appendWord: state.quickSearchWords.append(state.editingWord) - return .init(value: .syncQuickSearchWords) + return .send(.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 .send(.syncQuickSearchWords) } return .none case .deleteWord(let word): state.quickSearchWords = state.quickSearchWords.filter({ $0 != word }) - return .init(value: .syncQuickSearchWords) + return .send(.syncQuickSearchWords) case .deleteWordWithOffsets(let offsets): state.quickSearchWords.remove(atOffsets: offsets) - return .init(value: .syncQuickSearchWords) + return .send(.syncQuickSearchWords) case .moveWord(let source, let destination): state.quickSearchWords.move(fromOffsets: source, toOffset: destination) - return .init(value: .syncQuickSearchWords) + return .send(.syncQuickSearchWords) case .teardown: return .cancel(id: CancelID.fetchQuickSearchWords) case .fetchQuickSearchWords: state.loadingState = .loading - return databaseClient.fetchQuickSearchWords() - .map(Action.fetchQuickSearchWordsDone) + return .run(operation: { await $0(.fetchQuickSearchWordsDone(databaseClient.fetchQuickSearchWords())) }) .cancellable(id: CancelID.fetchQuickSearchWords) case .fetchQuickSearchWordsDone(let words): diff --git a/EhPanda/View/Search/Support/QuickSearchView.swift b/EhPanda/View/Search/Support/QuickSearchView.swift index 62e8e591..70e0556d 100644 --- a/EhPanda/View/Search/Support/QuickSearchView.swift +++ b/EhPanda/View/Search/Support/QuickSearchView.swift @@ -17,7 +17,7 @@ struct QuickSearchView: View { init(store: StoreOf, searchAction: @escaping (String) -> Void) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.searchAction = searchAction } @@ -54,7 +54,7 @@ struct QuickSearchView: View { .withArrow(isVisible: !viewStore.isListEditing).padding(5) .confirmationDialog( message: L10n.Localizable.ConfirmationDialog.Title.delete, - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /QuickSearchReducer.Route.deleteWord, matching: word ) { route in @@ -82,8 +82,8 @@ struct QuickSearchView: View { && viewStore.quickSearchWords.isEmpty ? 1 : 0 ) } - .synchronize(viewStore.binding(\.$focusedField), $focusedField) - .environment(\.editMode, viewStore.binding(\.$listEditMode)) + .synchronize(viewStore.$focusedField, $focusedField) + .environment(\.editMode, viewStore.$listEditMode) .animation(.default, value: viewStore.quickSearchWords) .animation(.default, value: viewStore.listEditMode) .onAppear { @@ -123,10 +123,10 @@ struct QuickSearchView: View { } } @ViewBuilder private var navigationLinks: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /QuickSearchReducer.Route.newWord) { _ in + NavigationLink(unwrapping: viewStore.$route, case: /QuickSearchReducer.Route.newWord) { _ in EditWordView( title: L10n.Localizable.QuickSearchView.Title.newWord, - word: viewStore.binding(\.$editingWord), + word: viewStore.$editingWord, focusedField: $focusedField, submitAction: onTextFieldSubmitted, confirmAction: { @@ -135,10 +135,10 @@ struct QuickSearchView: View { } ) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /QuickSearchReducer.Route.editWord) { _ in + NavigationLink(unwrapping: viewStore.$route, case: /QuickSearchReducer.Route.editWord) { _ in EditWordView( title: L10n.Localizable.QuickSearchView.Title.editWord, - word: viewStore.binding(\.$editingWord), + word: viewStore.$editingWord, focusedField: $focusedField, submitAction: onTextFieldSubmitted, confirmAction: { @@ -204,7 +204,7 @@ struct QuickSearchView_Previews: PreviewProvider { QuickSearchView( store: .init( initialState: .init(), - reducer: QuickSearchReducer() + reducer: QuickSearchReducer.init ), searchAction: { _ in } ) diff --git a/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift b/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift index 5cc9b7b6..8467b8e7 100644 --- a/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift +++ b/EhPanda/View/Setting/AccountSetting/AccountSettingReducer.swift @@ -33,8 +33,10 @@ struct AccountSettingReducer: ReducerProtocol { case setNavigation(Route?) case onLogoutConfirmButtonTapped case clearSubStates + case loadCookies case copyCookies(GalleryHost) + case login(LoginReducer.Action) case ehSetting(EhSettingReducer.Action) } @@ -52,10 +54,12 @@ struct AccountSettingReducer: ReducerProtocol { return state.route == nil ? .send(.clearSubStates) : .none case .binding(\.$ehCookiesState): - return cookieClient.setCookies(state: state.ehCookiesState).fireAndForget() + let ehCookiesState = state.ehCookiesState + return .run(operation: { _ in cookieClient.setCookies(state: ehCookiesState) }) case .binding(\.$exCookiesState): - return cookieClient.setCookies(state: state.exCookiesState).fireAndForget() + let exCookiesState = state.exCookiesState + return .run(operation: { _ in cookieClient.setCookies(state: exCookiesState) }) case .binding: return .none @@ -82,11 +86,11 @@ struct AccountSettingReducer: ReducerProtocol { case .copyCookies(let host): let cookiesDescription = cookieClient.getCookiesDescription(host: host) - return .merge( - .send(.setNavigation(.hud)), - clipboardClient.saveText(cookiesDescription).fireAndForget(), - .fireAndForget({ hapticsClient.generateNotificationFeedback(.success) }) - ) + return .run { send in + await send(.setNavigation(.hud)) + clipboardClient.saveText(cookiesDescription) + hapticsClient.generateNotificationFeedback(.success) + } case .login(.loginDone): return cookieClient.didLogin ? .send(.setNavigation(nil)) : .none diff --git a/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift b/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift index 6e796bd2..a3ede19a 100644 --- a/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift +++ b/EhPanda/View/Setting/AccountSetting/AccountSettingView.swift @@ -22,7 +22,7 @@ struct AccountSettingView: View { bypassesSNIFiltering: Bool, blurRadius: Double ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) _galleryHost = galleryHost _showsNewDawnGreeting = showsNewDawnGreeting self.bypassesSNIFiltering = bypassesSNIFiltering @@ -40,7 +40,7 @@ struct AccountSettingView: View { } .pickerStyle(.segmented) AccountSection( - route: viewStore.binding(\.$route), + route: viewStore.$route, showsNewDawnGreeting: $showsNewDawnGreeting, bypassesSNIFiltering: bypassesSNIFiltering, loginAction: { viewStore.send(.setNavigation(.login)) }, @@ -55,17 +55,17 @@ struct AccountSettingView: View { ) } CookieSection( - ehCookiesState: viewStore.binding(\.$ehCookiesState), - exCookiesState: viewStore.binding(\.$exCookiesState), + ehCookiesState: viewStore.$ehCookiesState, + exCookiesState: viewStore.$exCookiesState, copyAction: { viewStore.send(.copyCookies($0)) } ) } .progressHUD( config: viewStore.hudConfig, - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /AccountSettingReducer.Route.hud ) - .sheet(unwrapping: viewStore.binding(\.$route), case: /AccountSettingReducer.Route.webView) { route in + .sheet(unwrapping: viewStore.$route, case: /AccountSettingReducer.Route.webView) { route in WebView(url: route.wrappedValue) .autoBlur(radius: blurRadius) } @@ -78,13 +78,13 @@ struct AccountSettingView: View { // MARK: NavigationLinks private extension AccountSettingView { @ViewBuilder var navigationLinks: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /AccountSettingReducer.Route.login) { _ in + NavigationLink(unwrapping: viewStore.$route, case: /AccountSettingReducer.Route.login) { _ in LoginView( store: store.scope(state: \.loginState, action: AccountSettingReducer.Action.login), bypassesSNIFiltering: bypassesSNIFiltering, blurRadius: blurRadius ) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /AccountSettingReducer.Route.ehSetting) { _ in + NavigationLink(unwrapping: viewStore.$route, case: /AccountSettingReducer.Route.ehSetting) { _ in EhSettingView( store: store.scope(state: \.ehSettingState, action: AccountSettingReducer.Action.ehSetting), bypassesSNIFiltering: bypassesSNIFiltering, blurRadius: blurRadius @@ -224,7 +224,7 @@ struct AccountSettingView_Previews: PreviewProvider { AccountSettingView( store: .init( initialState: .init(), - reducer: AccountSettingReducer() + reducer: AccountSettingReducer.init ), galleryHost: .constant(.ehentai), showsNewDawnGreeting: .constant(false), diff --git a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift index 82fdcb6b..c1d02791 100644 --- a/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift +++ b/EhPanda/View/Setting/AppearanceSetting/AppearanceSettingView.swift @@ -31,7 +31,7 @@ struct AppearanceSettingView: View { displaysJapaneseTitle: Binding ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) _preferredColorScheme = preferredColorScheme _accentColor = accentColor _appIconType = appIconType @@ -107,7 +107,7 @@ struct AppearanceSettingView: View { } private var navigationLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /AppearanceSettingReducer.Route.appIcon) { _ in + NavigationLink(unwrapping: viewStore.$route, case: /AppearanceSettingReducer.Route.appIcon) { _ in AppIconView(appIconType: $appIconType) } } @@ -230,7 +230,7 @@ struct AppearanceSettingView_Previews: PreviewProvider { AppearanceSettingView( store: .init( initialState: .init(), - reducer: AppearanceSettingReducer() + reducer: AppearanceSettingReducer.init ), preferredColorScheme: .constant(.automatic), accentColor: .constant(.blue), diff --git a/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift b/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift index c2ece436..478df38b 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingReducer.swift @@ -67,13 +67,14 @@ struct EhSettingReducer: ReducerProtocol { return .none case .setKeyboardHidden: - return uiApplicationClient.hideKeyboard().fireAndForget() + return .run(operation: { _ in uiApplicationClient.hideKeyboard() }) case .setDefaultProfile(let profileSet): - return cookieClient.setOrEditCookie( - for: Defaults.URL.host, key: Defaults.Cookie.selectedProfile, value: String(profileSet) - ) - .fireAndForget() + return .run { _ in + cookieClient.setOrEditCookie( + for: Defaults.URL.host, key: Defaults.Cookie.selectedProfile, value: String(profileSet) + ) + } case .teardown: return .cancel(ids: CancelID.allCases) diff --git a/EhPanda/View/Setting/EhSetting/EhSettingView.swift b/EhPanda/View/Setting/EhSetting/EhSettingView.swift index 102c79b7..ff1c0540 100644 --- a/EhPanda/View/Setting/EhSetting/EhSettingView.swift +++ b/EhPanda/View/Setting/EhSetting/EhSettingView.swift @@ -16,7 +16,7 @@ struct EhSettingView: View { init(store: StoreOf, bypassesSNIFiltering: Bool, blurRadius: Double) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.bypassesSNIFiltering = bypassesSNIFiltering self.blurRadius = blurRadius } @@ -33,8 +33,8 @@ struct EhSettingView: View { .tint(nil) } // Using `Binding.init` will crash the app - else if let ehSetting = Binding(unwrapping: viewStore.binding(\.$ehSetting)), - let ehProfile = Binding(unwrapping: viewStore.binding(\.$ehProfile)) + else if let ehSetting = Binding(unwrapping: viewStore.$ehSetting), + let ehProfile = Binding(unwrapping: viewStore.$ehProfile) { form(ehSetting: ehSetting, ehProfile: ehProfile) .transition(.opacity.animation(.default)) @@ -50,7 +50,7 @@ struct EhSettingView: View { viewStore.send(.setDefaultProfile(profileSet)) } } - .sheet(unwrapping: viewStore.binding(\.$route), case: /EhSettingReducer.Route.webView) { route in + .sheet(unwrapping: viewStore.$route, case: /EhSettingReducer.Route.webView) { route in WebView(url: route.wrappedValue) .autoBlur(radius: blurRadius) } @@ -62,10 +62,10 @@ struct EhSettingView: View { Form { Group { EhProfileSection( - route: viewStore.binding(\.$route), + route: viewStore.$route, ehSetting: ehSetting, ehProfile: ehProfile, - editingProfileName: viewStore.binding(\.$editingProfileName), + editingProfileName: viewStore.$editingProfileName, deleteAction: { if let value = viewStore.ehProfile?.value { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { @@ -1021,7 +1021,7 @@ struct EhSettingView_Previews: PreviewProvider { EhSettingView( store: .init( initialState: .init(ehSetting: .empty, ehProfile: .empty, loadingState: .idle), - reducer: EhSettingReducer() + reducer: EhSettingReducer.init ), bypassesSNIFiltering: false, blurRadius: 0 diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift index 1fcb65d4..57a46367 100644 --- a/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift +++ b/EhPanda/View/Setting/GeneralSetting/GeneralSettingReducer.swift @@ -53,18 +53,18 @@ struct GeneralSettingReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$route): - return state.route == nil ? .init(value: .clearSubStates) : .none + return state.route == nil ? .send(.clearSubStates) : .none case .binding: return .none case .setNavigation(let route): state.route = route - return route == nil ? .init(value: .clearSubStates) : .none + return route == nil ? .send(.clearSubStates) : .none case .clearSubStates: state.logsState = .init() - return .init(value: .logs(.teardown)) + return .send(.logs(.teardown)) case .onTranslationsFilePicked: return .none @@ -73,22 +73,23 @@ struct GeneralSettingReducer: ReducerProtocol { return .none case .clearWebImageCache: - return .merge( - libraryClient.clearWebImageDiskCache().fireAndForget(), - databaseClient.removeImageURLs().fireAndForget(), - .init(value: .calculateWebImageDiskCache) - ) + return .run { send in + libraryClient.clearWebImageDiskCache() + await databaseClient.removeImageURLs() + await send(.calculateWebImageDiskCache) + } case .checkPasscodeSetting: state.passcodeNotSet = authorizationClient.passcodeNotSet() return .none case .navigateToSystemSetting: - return uiApplicationClient.openSettings().fireAndForget() + return .run(operation: { _ in uiApplicationClient.openSettings() }) case .calculateWebImageDiskCache: - return libraryClient.calculateWebImageDiskCacheSize() - .map(Action.calculateWebImageDiskCacheDone) + return .run { send in + await send(.calculateWebImageDiskCacheDone(libraryClient.calculateWebImageDiskCacheSize())) + } case .calculateWebImageDiskCacheDone(let bytes): guard let bytes = bytes else { return .none } diff --git a/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift b/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift index f85c77db..f15be11c 100644 --- a/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift +++ b/EhPanda/View/Setting/GeneralSetting/GeneralSettingView.swift @@ -34,7 +34,7 @@ struct GeneralSettingView: View { autoLockPolicy: Binding ) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.tagTranslatorLoadingState = tagTranslatorLoadingState self.tagTranslatorEmpty = tagTranslatorEmpty self.tagTranslatorHasCustomTranslations = tagTranslatorHasCustomTranslations @@ -106,7 +106,7 @@ struct GeneralSettingView: View { ) .confirmationDialog( message: L10n.Localizable.ConfirmationDialog.Title.removeCustomTranslations, - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /GeneralSettingReducer.Route.removeCustomTranslations ) { Button(L10n.Localizable.ConfirmationDialog.Button.remove, role: .destructive) { @@ -164,7 +164,7 @@ struct GeneralSettingView: View { } .confirmationDialog( message: L10n.Localizable.ConfirmationDialog.Title.clear, - unwrapping: viewStore.binding(\.$route), + unwrapping: viewStore.$route, case: /GeneralSettingReducer.Route.clearCache ) { Button(L10n.Localizable.ConfirmationDialog.Button.clear, role: .destructive) { @@ -186,7 +186,7 @@ struct GeneralSettingView: View { } private var navigationLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /GeneralSettingReducer.Route.logs) { _ in + NavigationLink(unwrapping: viewStore.$route, case: /GeneralSettingReducer.Route.logs) { _ in LogsView(store: store.scope(state: \.logsState, action: GeneralSettingReducer.Action.logs)) } } @@ -198,7 +198,7 @@ struct GeneralSettingView_Previews: PreviewProvider { GeneralSettingView( store: .init( initialState: .init(), - reducer: GeneralSettingReducer() + reducer: GeneralSettingReducer.init ), tagTranslatorLoadingState: .idle, tagTranslatorEmpty: false, diff --git a/EhPanda/View/Setting/Login/LoginReducer.swift b/EhPanda/View/Setting/Login/LoginReducer.swift index fe2c7803..c98db987 100644 --- a/EhPanda/View/Setting/Login/LoginReducer.swift +++ b/EhPanda/View/Setting/Login/LoginReducer.swift @@ -69,24 +69,28 @@ struct LoginReducer: ReducerProtocol { 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) - ) + let username = state.username + let password = state.password + return .run { send in + hapticsClient.generateFeedback(.soft) + + let result = await LoginRequest(username: username, password: password).process() + await send(.loginDone(result)) + } + .cancellable(id: CancelID.login) case .loginDone(let result): state.route = nil - var effects = [EffectTask]() + var effects = [Effect]() if cookieClient.didLogin { state.loginState = .idle - effects.append(.fireAndForget({ hapticsClient.generateNotificationFeedback(.success) })) + effects.append(.run(operation: { _ in hapticsClient.generateNotificationFeedback(.success) })) } else { state.loginState = .failed(.unknown) - effects.append(.fireAndForget({ hapticsClient.generateNotificationFeedback(.error) })) + effects.append(.run(operation: { _ in hapticsClient.generateNotificationFeedback(.error) })) } if case .success(let response) = result, let response = response { - effects.append(cookieClient.setCredentials(response: response).fireAndForget()) + effects.append(.run(operation: { _ in cookieClient.setCredentials(response: response) })) } return .merge(effects) } diff --git a/EhPanda/View/Setting/Login/LoginView.swift b/EhPanda/View/Setting/Login/LoginView.swift index eb5bde54..cde08d43 100644 --- a/EhPanda/View/Setting/Login/LoginView.swift +++ b/EhPanda/View/Setting/Login/LoginView.swift @@ -18,7 +18,7 @@ struct LoginView: View { init(store: StoreOf, bypassesSNIFiltering: Bool, blurRadius: Double) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.bypassesSNIFiltering = bypassesSNIFiltering self.blurRadius = blurRadius } @@ -35,11 +35,11 @@ struct LoginView: View { VStack(spacing: 15) { Group { LoginTextField( - focusedField: $focusedField, text: viewStore.binding(\.$username), + focusedField: $focusedField, text: viewStore.$username, description: L10n.Localizable.LoginView.Title.username, isPassword: false ) LoginTextField( - focusedField: $focusedField, text: viewStore.binding(\.$password), + focusedField: $focusedField, text: viewStore.$password, description: L10n.Localizable.LoginView.Title.password, isPassword: true ) } @@ -55,8 +55,8 @@ struct LoginView: View { } } } - .synchronize(viewStore.binding(\.$focusedField), $focusedField) - .sheet(unwrapping: viewStore.binding(\.$route), case: /LoginReducer.Route.webView) { route in + .synchronize(viewStore.$focusedField, $focusedField) + .sheet(unwrapping: viewStore.$route, case: /LoginReducer.Route.webView) { route in WebView(url: route.wrappedValue) { viewStore.send(.loginDone(.success(nil))) } @@ -136,7 +136,7 @@ struct LoginView_Previews: PreviewProvider { LoginView( store: .init( initialState: .init(), - reducer: LoginReducer() + reducer: LoginReducer.init ), bypassesSNIFiltering: false, blurRadius: 0 diff --git a/EhPanda/View/Setting/Logs/LogsReducer.swift b/EhPanda/View/Setting/Logs/LogsReducer.swift index 2b9e72d1..a4d23f41 100644 --- a/EhPanda/View/Setting/Logs/LogsReducer.swift +++ b/EhPanda/View/Setting/Logs/LogsReducer.swift @@ -50,7 +50,7 @@ struct LogsReducer: ReducerProtocol { return .none case .navigateToFileApp: - return uiApplicationClient.openFileApp().fireAndForget() + return .run(operation: { _ in uiApplicationClient.openFileApp() }) case .teardown: return .cancel(id: CancelID.fetchLogs) @@ -58,7 +58,8 @@ struct LogsReducer: ReducerProtocol { case .fetchLogs: guard state.loadingState != .loading else { return .none } state.loadingState = .loading - return fileClient.fetchLogs().map(Action.fetchLogsDone).cancellable(id: CancelID.fetchLogs) + return .run(operation: { await $0(.fetchLogsDone(fileClient.fetchLogs())) }) + .cancellable(id: CancelID.fetchLogs) case .fetchLogsDone(let result): switch result { @@ -71,7 +72,7 @@ struct LogsReducer: ReducerProtocol { return .none case .deleteLog(let fileName): - return fileClient.deleteLog(fileName).map(Action.deleteLogDone) + return .run(operation: { await $0(.deleteLogDone(fileClient.deleteLog(fileName))) }) case .deleteLogDone(let result): if case .success(let fileName) = result { diff --git a/EhPanda/View/Setting/Logs/LogsView.swift b/EhPanda/View/Setting/Logs/LogsView.swift index 0d519b11..f00c8c8c 100644 --- a/EhPanda/View/Setting/Logs/LogsView.swift +++ b/EhPanda/View/Setting/Logs/LogsView.swift @@ -14,7 +14,7 @@ struct LogsView: View { init(store: StoreOf) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) } var body: some View { @@ -56,7 +56,7 @@ struct LogsView: View { } private var navigationLink: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /LogsReducer.Route.log) { route in + NavigationLink(unwrapping: viewStore.$route, case: /LogsReducer.Route.log) { route in LogView(log: route.wrappedValue) } } @@ -177,7 +177,7 @@ struct LogsView_Previews: PreviewProvider { LogsView( store: .init( initialState: .init(), - reducer: LogsReducer() + reducer: LogsReducer.init ) ) } diff --git a/EhPanda/View/Setting/SettingReducer.swift b/EhPanda/View/Setting/SettingReducer.swift index 6aec4aac..d85ea8be 100644 --- a/EhPanda/View/Setting/SettingReducer.swift +++ b/EhPanda/View/Setting/SettingReducer.swift @@ -115,33 +115,34 @@ struct SettingReducer: ReducerProtocol { Reduce { state, action in switch action { case .binding(\.$setting.galleryHost): - return .merge( - .init(value: .syncSetting), - userDefaultsClient - .setValue(state.setting.galleryHost.rawValue, .galleryHost).fireAndForget() - ) + let galleryHost = state.setting.galleryHost.rawValue + return .run { send in + await send(.syncSetting) + userDefaultsClient.setValue(galleryHost, .galleryHost) + } case .binding(\.$setting.enablesTagsExtension): - var effects: [EffectTask] = [ - .init(value: .syncSetting) + var effects: [Effect] = [ + .send(.syncSetting) ] if state.setting.enablesTagsExtension { - effects.append(.init(value: .fetchTagTranslator)) + effects.append(.send(.fetchTagTranslator)) } return .merge(effects) case .binding(\.$setting.preferredColorScheme): return .merge( - .init(value: .syncSetting), - .init(value: .syncUserInterfaceStyle) + .send(.syncSetting), + .send(.syncUserInterfaceStyle) ) case .binding(\.$setting.appIconType): - return .merge( - .init(value: .syncSetting), - uiApplicationClient.setAlternateIconName(state.setting.appIconType.filename) - .map { _ in Action.syncAppIconType } - ) + let filename = state.setting.appIconType.filename + return .run { send in + await send(.syncSetting) + _ = await uiApplicationClient.setAlternateIconName(filename) + await send(.syncAppIconType) + } case .binding(\.$setting.autoLockPolicy): if state.setting.autoLockPolicy != .never @@ -149,7 +150,7 @@ struct SettingReducer: ReducerProtocol { { state.setting.backgroundBlurRadius = 10 } - return .init(value: .syncSetting) + return .send(.syncSetting) case .binding(\.$setting.backgroundBlurRadius): if state.setting.autoLockPolicy != .never @@ -157,14 +158,14 @@ struct SettingReducer: ReducerProtocol { { state.setting.autoLockPolicy = .never } - return .init(value: .syncSetting) + return .send(.syncSetting) case .binding(\.$setting.enablesLandscape): - var effects: [EffectTask] = [ - .init(value: .syncSetting) + var effects: [Effect] = [ + .send(.syncSetting) ] if !state.setting.enablesLandscape && !deviceClient.isPad() { - effects.append(appDelegateClient.setPortraitOrientationMask().fireAndForget()) + effects.append(.run(operation: { _ in appDelegateClient.setPortraitOrientationMask() })) } return .merge(effects) @@ -172,32 +173,33 @@ struct SettingReducer: ReducerProtocol { if state.setting.doubleTapScaleFactor > state.setting.maximumScaleFactor { state.setting.doubleTapScaleFactor = state.setting.maximumScaleFactor } - return .init(value: .syncSetting) + return .send(.syncSetting) case .binding(\.$setting.doubleTapScaleFactor): if state.setting.maximumScaleFactor < state.setting.doubleTapScaleFactor { state.setting.maximumScaleFactor = state.setting.doubleTapScaleFactor } - return .init(value: .syncSetting) + return .send(.syncSetting) case .binding(\.$setting.bypassesSNIFiltering): - return .merge( - .init(value: .syncSetting), - .fireAndForget({ hapticsClient.generateFeedback(.soft) }), - dfClient.setActive(state.setting.bypassesSNIFiltering).fireAndForget() - ) + let bypassesSNIFiltering = state.setting.bypassesSNIFiltering + return .run { send in + await send(.syncSetting) + hapticsClient.generateFeedback(.soft) + dfClient.setActive(bypassesSNIFiltering) + } case .binding(\.$setting): - return .init(value: .syncSetting) + return .send(.syncSetting) case .binding(\.$route): return .none case .binding: return .merge( - .init(value: .syncUser), - .init(value: .syncSetting), - .init(value: .syncTagTranslator) + .send(.syncUser), + .send(.syncSetting), + .send(.syncTagTranslator) ) case .setNavigation(let route): @@ -220,28 +222,33 @@ struct SettingReducer: ReducerProtocol { case .syncUserInterfaceStyle: let style = state.setting.preferredColorScheme.userInterfaceStyle - return uiApplicationClient.setUserInterfaceStyle(style) - .subscribe(on: DispatchQueue.main).fireAndForget() + return .run(operation: { _ in uiApplicationClient.setUserInterfaceStyle(style) }) case .syncSetting: - return databaseClient.updateSetting(state.setting).fireAndForget() + let setting = state.setting + return .run(operation: { _ in await databaseClient.cacheSetting(setting) }) case .syncTagTranslator: - return databaseClient.updateTagTranslator(state.tagTranslator).fireAndForget() + let tagTranslator = state.tagTranslator + return .run(operation: { _ in await databaseClient.cacheTagTranslator(tagTranslator) }) case .syncUser: - return databaseClient.updateUser(state.user).fireAndForget() + let user = state.user + return .run(operation: { _ in await databaseClient.cacheUser(user) }) case .loadUserSettings: - return databaseClient.fetchAppEnv().map(Action.onLoadUserSettings) + return .run { send in + await send(.onLoadUserSettings(databaseClient.fetchAppEnv())) + } 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() + let bypassesSNIFiltering = state.setting.bypassesSNIFiltering + var effects: [Effect] = [ + .send(.syncAppIconType), + .send(.loadUserSettingsDone), + .send(.syncUserInterfaceStyle), + .run(operation: { _ in dfClient.setActive(bypassesSNIFiltering) }) ] if let value: String = userDefaultsClient.getValue(.galleryHost), let galleryHost = GalleryHost(rawValue: value) @@ -249,18 +256,18 @@ struct SettingReducer: ReducerProtocol { state.setting.galleryHost = galleryHost } if cookieClient.shouldFetchIgneous { - effects.append(.init(value: .fetchIgneous)) + effects.append(.send(.fetchIgneous)) } if cookieClient.didLogin { effects.append(contentsOf: [ - .init(value: .fetchUserInfo), - .init(value: .fetchGreeting), - .init(value: .fetchFavoriteCategories), - .init(value: .fetchEhProfileIndex) + .send(.fetchUserInfo), + .send(.fetchGreeting), + .send(.fetchFavoriteCategories), + .send(.fetchEhProfileIndex) ]) } if state.setting.enablesTagsExtension { - effects.append(.init(value: .fetchTagTranslator)) + effects.append(.send(.fetchTagTranslator)) } return .merge(effects) @@ -269,18 +276,18 @@ struct SettingReducer: ReducerProtocol { return .none case .createDefaultEhProfile: - return EhProfileRequest(action: .create, name: "EhPanda").effect.fireAndForget() + return .run(operation: { _ in await _ = EhProfileRequest(action: .create, name: "EhPanda").process() }) case .fetchIgneous: guard cookieClient.didLogin else { return .none } return IgneousRequest().effect.map(Action.fetchIgneousDone) case .fetchIgneousDone(let result): - var effects = [EffectTask]() + var effects = [Effect]() if case .success(let response) = result { - effects.append(cookieClient.setCredentials(response: response).fireAndForget()) + effects.append(.run(operation: { _ in cookieClient.setCredentials(response: response) })) } - effects.append(.init(value: .account(.loadCookies))) + effects.append(.send(.account(.loadCookies))) return .merge(effects) case .fetchUserInfo: @@ -295,7 +302,7 @@ struct SettingReducer: ReducerProtocol { case .fetchUserInfoDone(let result): if case .success(let user) = result { state.updateUser(user) - return .init(value: .syncUser) + return .send(.syncUser) } return .none @@ -335,13 +342,13 @@ struct SettingReducer: ReducerProtocol { switch result { case .success(let greeting): state.setGreeting(greeting) - return .init(value: .syncUser) + return .send(.syncUser) case .failure(let error): if case .parseFailed = error { var greeting = Greeting() greeting.updateTime = Date() state.setGreeting(greeting) - return .init(value: .syncUser) + return .send(.syncUser) } } return .none @@ -353,10 +360,10 @@ struct SettingReducer: ReducerProtocol { else { return .none } state.tagTranslatorLoadingState = .loading - var databaseEffect: EffectTask? + var databaseEffect: Effect? if state.tagTranslator.language != language { state.tagTranslator = TagTranslator(language: language) - databaseEffect = .init(value: .syncTagTranslator) + databaseEffect = .send(.syncTagTranslator) } let updatedDate = state.tagTranslator.updatedDate let requestEffect = TagTranslatorRequest(language: language, updatedDate: updatedDate) @@ -372,7 +379,7 @@ struct SettingReducer: ReducerProtocol { switch result { case .success(let tagTranslator): state.tagTranslator = tagTranslator - return .init(value: .syncTagTranslator) + return .send(.syncTagTranslator) case .failure(let error): state.tagTranslatorLoadingState = .failed(error) } @@ -383,7 +390,7 @@ struct SettingReducer: ReducerProtocol { return VerifyEhProfileRequest().effect.map(Action.fetchEhProfileIndexDone) case .fetchEhProfileIndexDone(let result): - var effects = [EffectTask]() + var effects = [Effect]() if case .success(let response) = result { if let profileValue = response.profileValue { @@ -394,17 +401,18 @@ struct SettingReducer: ReducerProtocol { let cookieValue = cookieClient.getCookie(hostURL, selectedProfileKey) if cookieValue.rawValue != profileValueString { effects.append( - cookieClient.setOrEditCookie( - for: hostURL, key: selectedProfileKey, value: profileValueString - ) - .fireAndForget() + .run { _ in + cookieClient.setOrEditCookie( + for: hostURL, key: selectedProfileKey, value: profileValueString + ) + } ) } } else if response.isProfileNotFound { - effects.append(.init(value: .createDefaultEhProfile)) + effects.append(.send(.createDefaultEhProfile)) } else { let message = "Found profile but failed in parsing value." - effects.append(loggerClient.error(message, nil).fireAndForget()) + effects.append(.run(operation: { _ in loggerClient.error(message, nil) })) } } return effects.isEmpty ? .none : .merge(effects) @@ -420,35 +428,35 @@ struct SettingReducer: ReducerProtocol { 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) - ) + return .run { send in + cookieClient.removeYay() + cookieClient.syncExCookies() + cookieClient.fulfillAnotherHostField() + await send(.fetchIgneous) + await send(.fetchUserInfo) + await send(.fetchFavoriteCategories) + await send(.fetchEhProfileIndex) + } case .account(.onLogoutConfirmButtonTapped): state.user = User() - return .merge( - .init(value: .syncUser), - cookieClient.clearAll().fireAndForget(), - databaseClient.removeImageURLs().fireAndForget(), - libraryClient.clearWebImageDiskCache().fireAndForget() - ) + return .run { send in + await send(.syncUser) + cookieClient.clearAll() + await databaseClient.removeImageURLs() + libraryClient.clearWebImageDiskCache() + } case .account: return .none case .general(.onTranslationsFilePicked(let url)): - return fileClient.importTagTranslator(url).map(Action.fetchTagTranslatorDone) + return .run(operation: { await $0(.fetchTagTranslatorDone(fileClient.importTagTranslator(url))) }) case .general(.onRemoveCustomTranslations): state.tagTranslator.hasCustomTranslations = false state.tagTranslator.translations = .init() - return .init(value: .syncTagTranslator) + return .send(.syncTagTranslator) case .general: return .none diff --git a/EhPanda/View/Setting/SettingView.swift b/EhPanda/View/Setting/SettingView.swift index f4d267b2..eed21c61 100644 --- a/EhPanda/View/Setting/SettingView.swift +++ b/EhPanda/View/Setting/SettingView.swift @@ -16,7 +16,7 @@ struct SettingView: View { init(store: StoreOf, blurRadius: Double) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) self.blurRadius = blurRadius } @@ -42,64 +42,64 @@ struct SettingView: View { // MARK: NavigationLinks private extension SettingView { @ViewBuilder var navigationLinks: some View { - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingReducer.Route.account) { _ in + NavigationLink(unwrapping: viewStore.$route, case: /SettingReducer.Route.account) { _ in AccountSettingView( store: store.scope(state: \.accountSettingState, action: SettingReducer.Action.account), - galleryHost: viewStore.binding(\.$setting.galleryHost), - showsNewDawnGreeting: viewStore.binding(\.$setting.showsNewDawnGreeting), + galleryHost: viewStore.$setting.galleryHost, + showsNewDawnGreeting: viewStore.$setting.showsNewDawnGreeting, bypassesSNIFiltering: viewStore.setting.bypassesSNIFiltering, blurRadius: blurRadius ) .tint(viewStore.setting.accentColor) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingReducer.Route.general) { _ in + NavigationLink(unwrapping: viewStore.$route, case: /SettingReducer.Route.general) { _ in GeneralSettingView( store: store.scope(state: \.generalSettingState, action: SettingReducer.Action.general), tagTranslatorLoadingState: viewStore.tagTranslatorLoadingState, tagTranslatorEmpty: viewStore.tagTranslator.translations.isEmpty, tagTranslatorHasCustomTranslations: viewStore.tagTranslator.hasCustomTranslations, - enablesTagsExtension: viewStore.binding(\.$setting.enablesTagsExtension), - translatesTags: viewStore.binding(\.$setting.translatesTags), - showsTagsSearchSuggestion: viewStore.binding(\.$setting.showsTagsSearchSuggestion), - showsImagesInTags: viewStore.binding(\.$setting.showsImagesInTags), - redirectsLinksToSelectedHost: viewStore.binding(\.$setting.redirectsLinksToSelectedHost), - detectsLinksFromClipboard: viewStore.binding(\.$setting.detectsLinksFromClipboard), - backgroundBlurRadius: viewStore.binding(\.$setting.backgroundBlurRadius), - autoLockPolicy: viewStore.binding(\.$setting.autoLockPolicy) + enablesTagsExtension: viewStore.$setting.enablesTagsExtension, + translatesTags: viewStore.$setting.translatesTags, + showsTagsSearchSuggestion: viewStore.$setting.showsTagsSearchSuggestion, + showsImagesInTags: viewStore.$setting.showsImagesInTags, + redirectsLinksToSelectedHost: viewStore.$setting.redirectsLinksToSelectedHost, + detectsLinksFromClipboard: viewStore.$setting.detectsLinksFromClipboard, + backgroundBlurRadius: viewStore.$setting.backgroundBlurRadius, + autoLockPolicy: viewStore.$setting.autoLockPolicy ) .tint(viewStore.setting.accentColor) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingReducer.Route.appearance) { _ in + NavigationLink(unwrapping: viewStore.$route, case: /SettingReducer.Route.appearance) { _ in AppearanceSettingView( store: store.scope(state: \.appearanceSettingState, action: SettingReducer.Action.appearance), - preferredColorScheme: viewStore.binding(\.$setting.preferredColorScheme), - accentColor: viewStore.binding(\.$setting.accentColor), - appIconType: viewStore.binding(\.$setting.appIconType), - listDisplayMode: viewStore.binding(\.$setting.listDisplayMode), - showsTagsInList: viewStore.binding(\.$setting.showsTagsInList), - listTagsNumberMaximum: viewStore.binding(\.$setting.listTagsNumberMaximum), - displaysJapaneseTitle: viewStore.binding(\.$setting.displaysJapaneseTitle) + preferredColorScheme: viewStore.$setting.preferredColorScheme, + accentColor: viewStore.$setting.accentColor, + appIconType: viewStore.$setting.appIconType, + listDisplayMode: viewStore.$setting.listDisplayMode, + showsTagsInList: viewStore.$setting.showsTagsInList, + listTagsNumberMaximum: viewStore.$setting.listTagsNumberMaximum, + displaysJapaneseTitle: viewStore.$setting.displaysJapaneseTitle ) .tint(viewStore.setting.accentColor) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingReducer.Route.reading) { _ in + NavigationLink(unwrapping: viewStore.$route, case: /SettingReducer.Route.reading) { _ in ReadingSettingView( - readingDirection: viewStore.binding(\.$setting.readingDirection), - prefetchLimit: viewStore.binding(\.$setting.prefetchLimit), - enablesLandscape: viewStore.binding(\.$setting.enablesLandscape), - contentDividerHeight: viewStore.binding(\.$setting.contentDividerHeight), - maximumScaleFactor: viewStore.binding(\.$setting.maximumScaleFactor), - doubleTapScaleFactor: viewStore.binding(\.$setting.doubleTapScaleFactor) + readingDirection: viewStore.$setting.readingDirection, + prefetchLimit: viewStore.$setting.prefetchLimit, + enablesLandscape: viewStore.$setting.enablesLandscape, + contentDividerHeight: viewStore.$setting.contentDividerHeight, + maximumScaleFactor: viewStore.$setting.maximumScaleFactor, + doubleTapScaleFactor: viewStore.$setting.doubleTapScaleFactor ) .tint(viewStore.setting.accentColor) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingReducer.Route.laboratory) { _ in + NavigationLink(unwrapping: viewStore.$route, case: /SettingReducer.Route.laboratory) { _ in LaboratorySettingView( - bypassesSNIFiltering: viewStore.binding(\.$setting.bypassesSNIFiltering) + bypassesSNIFiltering: viewStore.$setting.bypassesSNIFiltering ) .tint(viewStore.setting.accentColor) } - NavigationLink(unwrapping: viewStore.binding(\.$route), case: /SettingReducer.Route.about) { _ in + NavigationLink(unwrapping: viewStore.$route, case: /SettingReducer.Route.about) { _ in AboutView().tint(viewStore.setting.accentColor) } } @@ -185,7 +185,7 @@ struct SettingView_Previews: PreviewProvider { SettingView( store: .init( initialState: .init(), - reducer: SettingReducer() + reducer: SettingReducer.init ), blurRadius: 0 ) diff --git a/EhPanda/View/Support/FiltersReducer.swift b/EhPanda/View/Support/FiltersReducer.swift index 83121f01..047a12f8 100644 --- a/EhPanda/View/Support/FiltersReducer.swift +++ b/EhPanda/View/Support/FiltersReducer.swift @@ -47,15 +47,15 @@ struct FiltersReducer: ReducerProtocol { switch action { case .binding(\.$searchFilter): state.searchFilter.fixInvalidData() - return .init(value: .syncFilter(.search)) + return .send(.syncFilter(.search)) case .binding(\.$globalFilter): state.globalFilter.fixInvalidData() - return .init(value: .syncFilter(.global)) + return .send(.syncFilter(.global)) case .binding(\.$watchedFilter): state.watchedFilter.fixInvalidData() - return .init(value: .syncFilter(.watched)) + return .send(.syncFilter(.watched)) case .binding: return .none @@ -85,23 +85,25 @@ struct FiltersReducer: ReducerProtocol { case .watched: filter = state.watchedFilter } - return databaseClient.updateFilter(filter, range: range).fireAndForget() + return .run(operation: { _ in await databaseClient.cacheFilter(filter, range: range) }) case .resetFilters: switch state.filterRange { case .search: state.searchFilter = .init() - return .init(value: .syncFilter(.search)) + return .send(.syncFilter(.search)) case .global: state.globalFilter = .init() - return .init(value: .syncFilter(.global)) + return .send(.syncFilter(.global)) case .watched: state.watchedFilter = .init() - return .init(value: .syncFilter(.watched)) + return .send(.syncFilter(.watched)) } case .fetchFilters: - return databaseClient.fetchAppEnv().map(Action.fetchFiltersDone) + return .run { send in + await send(.fetchFiltersDone(databaseClient.fetchAppEnv())) + } case .fetchFiltersDone(let appEnv): state.searchFilter = appEnv.searchFilter diff --git a/EhPanda/View/Support/FiltersView.swift b/EhPanda/View/Support/FiltersView.swift index 5ac42d18..b2a9d82b 100644 --- a/EhPanda/View/Support/FiltersView.swift +++ b/EhPanda/View/Support/FiltersView.swift @@ -16,17 +16,17 @@ struct FiltersView: View { init(store: StoreOf) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) } private var filter: Binding { switch viewStore.filterRange { case .search: - return viewStore.binding(\.$searchFilter) + return viewStore.$searchFilter case .global: - return viewStore.binding(\.$globalFilter) + return viewStore.$globalFilter case .watched: - return viewStore.binding(\.$watchedFilter) + return viewStore.$watchedFilter } } @@ -35,8 +35,8 @@ struct FiltersView: View { NavigationView { Form { BasicSection( - route: viewStore.binding(\.$route), - filter: filter, filterRange: viewStore.binding(\.$filterRange), + route: viewStore.$route, + filter: filter, filterRange: viewStore.$filterRange, resetFiltersAction: { viewStore.send(.resetFilters) }, resetFiltersDialogAction: { viewStore.send(.setNavigation(.resetFilters)) } ) @@ -45,7 +45,7 @@ struct FiltersView: View { submitAction: { viewStore.send(.onTextFieldSubmitted) } ) } - .synchronize(viewStore.binding(\.$focusedBound), $focusedBound) + .synchronize(viewStore.$focusedBound, $focusedBound) .navigationTitle(L10n.Localizable.FiltersView.Title.filters) .onAppear { viewStore.send(.fetchFilters) } } @@ -242,7 +242,7 @@ struct FiltersView_Previews: PreviewProvider { FiltersView( store: .init( initialState: .init(), - reducer: FiltersReducer() + reducer: FiltersReducer.init ) ) } diff --git a/EhPanda/View/TabBar/TabBarView.swift b/EhPanda/View/TabBar/TabBarView.swift index 5c4a83a3..2867ed9c 100644 --- a/EhPanda/View/TabBar/TabBarView.swift +++ b/EhPanda/View/TabBar/TabBarView.swift @@ -16,93 +16,93 @@ struct TabBarView: View { init(store: StoreOf) { self.store = store - viewStore = ViewStore(store) + viewStore = ViewStore(store, observe: { $0 }) } var body: some View { ZStack { - TabView( - selection: .init( - get: { viewStore.tabBarState.tabBarItemType }, - set: { viewStore.send(.tabBar(.setTabBarItemType($0))) } - ) - ) { - ForEach(TabBarItemType.allCases) { type in - Group { - switch type { - case .home: - HomeView( - store: store.scope(state: \.homeState, action: AppReducer.Action.home), - user: viewStore.settingState.user, - setting: viewStore.binding(\.settingState.$setting), - blurRadius: viewStore.appLockState.blurRadius, - tagTranslator: viewStore.settingState.tagTranslator - ) - case .favorites: - FavoritesView( - store: store.scope(state: \.favoritesState, action: AppReducer.Action.favorites), - user: viewStore.settingState.user, - setting: viewStore.binding(\.settingState.$setting), - blurRadius: viewStore.appLockState.blurRadius, - tagTranslator: viewStore.settingState.tagTranslator - ) - case .search: - SearchRootView( - store: store.scope(state: \.searchRootState, action: AppReducer.Action.searchRoot), - user: viewStore.settingState.user, - setting: viewStore.binding(\.settingState.$setting), - blurRadius: viewStore.appLockState.blurRadius, - tagTranslator: viewStore.settingState.tagTranslator - ) - case .setting: - SettingView( - store: store.scope(state: \.settingState, action: AppReducer.Action.setting), - blurRadius: viewStore.appLockState.blurRadius - ) - } - } - .tabItem(type.label).tag(type) - } - .accentColor(viewStore.settingState.setting.accentColor) - } - .autoBlur(radius: viewStore.appLockState.blurRadius) - Button { - viewStore.send(.appLock(.authorize)) - } label: { - Image(systemSymbol: .lockFill) - } - .font(.system(size: 80)).opacity(viewStore.appLockState.isAppLocked ? 1 : 0) - } - .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: /AppRouteReducer.Route.setting) { _ in - SettingView( - 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: /AppRouteReducer.Route.detail) { route in - NavigationView { - DetailView( - 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, - tagTranslator: viewStore.settingState.tagTranslator - ) - } - .accentColor(viewStore.settingState.setting.accentColor) - .autoBlur(radius: viewStore.appLockState.blurRadius) - .environment(\.inSheet, true) - .navigationViewStyle(.stack) +// TabView( +// selection: .init( +// get: { viewStore.tabBarState.tabBarItemType }, +// set: { viewStore.send(.tabBar(.setTabBarItemType($0))) } +// ) +// ) { +// ForEach(TabBarItemType.allCases) { type in +// Group { +// switch type { +// case .home: +// HomeView( +// store: store.scope(state: \.homeState, action: AppReducer.Action.home), +// user: viewStore.settingState.user, +// setting: viewStore.binding(\.settingState.$setting), +// blurRadius: viewStore.appLockState.blurRadius, +// tagTranslator: viewStore.settingState.tagTranslator +// ) +// case .favorites: +// FavoritesView( +// store: store.scope(state: \.favoritesState, action: AppReducer.Action.favorites), +// user: viewStore.settingState.user, +// setting: viewStore.binding(\.settingState.$setting), +// blurRadius: viewStore.appLockState.blurRadius, +// tagTranslator: viewStore.settingState.tagTranslator +// ) +// case .search: +// SearchRootView( +// store: store.scope(state: \.searchRootState, action: AppReducer.Action.searchRoot), +// user: viewStore.settingState.user, +// setting: viewStore.binding(\.settingState.$setting), +// blurRadius: viewStore.appLockState.blurRadius, +// tagTranslator: viewStore.settingState.tagTranslator +// ) +// case .setting: +// SettingView( +// store: store.scope(state: \.settingState, action: AppReducer.Action.setting), +// blurRadius: viewStore.appLockState.blurRadius +// ) +// } +// } +// .tabItem(type.label).tag(type) +// } +// .accentColor(viewStore.settingState.setting.accentColor) +// } +// .autoBlur(radius: viewStore.appLockState.blurRadius) +// Button { +// viewStore.send(.appLock(.authorize)) +// } label: { +// Image(systemSymbol: .lockFill) +// } +// .font(.system(size: 80)).opacity(viewStore.appLockState.isAppLocked ? 1 : 0) } +// .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: /AppRouteReducer.Route.setting) { _ in +// SettingView( +// 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: /AppRouteReducer.Route.detail) { route in +// NavigationView { +// DetailView( +// 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, +// tagTranslator: viewStore.settingState.tagTranslator +// ) +// } +// .accentColor(viewStore.settingState.setting.accentColor) +// .autoBlur(radius: viewStore.appLockState.blurRadius) +// .environment(\.inSheet, true) +// .navigationViewStyle(.stack) +// } .progressHUD( config: viewStore.appRouteState.hudConfig, unwrapping: viewStore.binding(\.appRouteState.$route), @@ -158,7 +158,7 @@ struct TabBarView_Previews: PreviewProvider { TabBarView( store: .init( initialState: .init(), - reducer: AppReducer() + reducer: AppReducer.init ) ) }