diff --git a/App/Mochi.xcodeproj/project.pbxproj b/App/Mochi.xcodeproj/project.pbxproj index 053003c..4467b24 100644 --- a/App/Mochi.xcodeproj/project.pbxproj +++ b/App/Mochi.xcodeproj/project.pbxproj @@ -8,11 +8,11 @@ /* Begin PBXBuildFile section */ 132862252A17D06300F67EAC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132862242A17D06300F67EAC /* AppDelegate.swift */; }; - 1396B9E42A4B71BA00B7928A /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1396B9E32A4B71BA00B7928A /* SceneDelegate.swift */; }; - 1396B9E62A4B72A800B7928A /* HostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1396B9E52A4B72A800B7928A /* HostingController.swift */; }; + 1396B9E62A4B72A800B7928A /* PreferenceHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1396B9E52A4B72A800B7928A /* PreferenceHostingController.swift */; }; 1396FE0529DF561C00B22132 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1396FE0029DF561C00B22132 /* Assets.xcassets */; }; 1396FE0629DF561C00B22132 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1396FE0229DF561C00B22132 /* Preview Assets.xcassets */; }; 1396FE0729DF561C00B22132 /* MochiApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1396FE0329DF561C00B22132 /* MochiApp.swift */; }; + 13EDE7392B166E4500E14998 /* PreferenceHostingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EDE7382B166E4500E14998 /* PreferenceHostingView.swift */; }; 13F11CC02B11617D006FFF63 /* App in Frameworks */ = {isa = PBXBuildFile; productRef = 13F11CBF2B11617D006FFF63 /* App */; }; /* End PBXBuildFile section */ @@ -20,13 +20,13 @@ 132862242A17D06300F67EAC /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 134516B629DF44D200E4C3B8 /* mochi */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = mochi; path = ..; sourceTree = ""; }; 138DA7D52A0AB5E800FDAC13 /* mochi-info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "mochi-info.plist"; sourceTree = ""; }; - 1396B9E32A4B71BA00B7928A /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 1396B9E52A4B72A800B7928A /* HostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostingController.swift; sourceTree = ""; }; + 1396B9E52A4B72A800B7928A /* PreferenceHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceHostingController.swift; sourceTree = ""; }; 1396FDFF29DF561C00B22132 /* mochi.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = mochi.entitlements; sourceTree = ""; }; 1396FE0029DF561C00B22132 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 1396FE0229DF561C00B22132 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 1396FE0329DF561C00B22132 /* MochiApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MochiApp.swift; sourceTree = ""; }; 13C18B9129CE6CC200C14F26 /* Mochi.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mochi.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13EDE7382B166E4500E14998 /* PreferenceHostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceHostingView.swift; sourceTree = ""; }; 13F11CC12B116431006FFF63 /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS17.0.sdk/System/Library/Frameworks/Accelerate.framework; sourceTree = DEVELOPER_DIR; }; /* End PBXFileReference section */ @@ -81,8 +81,8 @@ 1396FE0429DF561C00B22132 /* iOS */ = { isa = PBXGroup; children = ( - 1396B9E32A4B71BA00B7928A /* SceneDelegate.swift */, - 1396B9E52A4B72A800B7928A /* HostingController.swift */, + 1396B9E52A4B72A800B7928A /* PreferenceHostingController.swift */, + 13EDE7382B166E4500E14998 /* PreferenceHostingView.swift */, ); path = iOS; sourceTree = ""; @@ -233,8 +233,8 @@ files = ( 132862252A17D06300F67EAC /* AppDelegate.swift in Sources */, 1396FE0729DF561C00B22132 /* MochiApp.swift in Sources */, - 1396B9E62A4B72A800B7928A /* HostingController.swift in Sources */, - 1396B9E42A4B71BA00B7928A /* SceneDelegate.swift in Sources */, + 1396B9E62A4B72A800B7928A /* PreferenceHostingController.swift in Sources */, + 13EDE7392B166E4500E14998 /* PreferenceHostingView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -385,7 +385,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 0.0.1; - PRODUCT_BUNDLE_IDENTIFIER = com.errorerrorerror.mochi; + PRODUCT_BUNDLE_IDENTIFIER = dev.errorerrorerror.mochi; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; @@ -424,7 +424,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 0.0.1; - PRODUCT_BUNDLE_IDENTIFIER = com.errorerrorerror.mochi; + PRODUCT_BUNDLE_IDENTIFIER = dev.errorerrorerror.mochi; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; diff --git a/App/Shared/AppDelegate.swift b/App/Shared/AppDelegate.swift index 2008c7b..ae1d33d 100644 --- a/App/Shared/AppDelegate.swift +++ b/App/Shared/AppDelegate.swift @@ -10,16 +10,15 @@ import App import Architecture import Foundation -#if os(iOS) +#if canImport(UIKit) import UIKit -let store = Store( - initialState: .init(), - reducer: { AppFeature() } -) - -@main class AppDelegate: UIResponder, UIApplicationDelegate { + let store = Store( + initialState: .init(), + reducer: { AppFeature() } + ) + func application( _: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil @@ -27,24 +26,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { store.send(.internal(.appDelegate(.didFinishLaunching))) return true } - - func application( - _: UIApplication, - configurationForConnecting connectingSceneSession: UISceneSession, - options _: UIScene.ConnectionOptions - ) -> UISceneConfiguration { - let configuration = UISceneConfiguration( - name: connectingSceneSession.configuration.name, - sessionRole: connectingSceneSession.role - ) - - configuration.delegateClass = SceneDelegate.self - - return configuration - } } - -#else +#elseif canImport(AppKit) import AppKit class AppDelegate: NSObject, NSApplicationDelegate { diff --git a/App/Shared/MochiApp.swift b/App/Shared/MochiApp.swift index f79bd1f..6c580d3 100644 --- a/App/Shared/MochiApp.swift +++ b/App/Shared/MochiApp.swift @@ -12,25 +12,42 @@ import Settings import SwiftUI import VideoPlayer -#if os(macOS) @main struct MochiApp: App { + #if canImport(UIKit) + @UIApplicationDelegateAdaptor(AppDelegate.self) + #elseif canImport(AppKit) @NSApplicationDelegateAdaptor(AppDelegate.self) + #endif var appDelegate var body: some Scene { WindowGroup { + #if os(iOS) + PreferenceHostingView { + AppFeature.View( + store: appDelegate.store + ) + } + // Ignoring safe area is required for + // PreferenceHostingView to render outside + // bounds + .ignoresSafeArea() + .themeable() + #elseif os(macOS) AppFeature.View( store: appDelegate.store ) - .themeable() .frame( minWidth: 800, maxWidth: .infinity, minHeight: 625, maxHeight: .infinity ) + .themeable() + #endif } + #if os(macOS) .windowStyle(.titleBar) .commands { SidebarCommands() @@ -38,7 +55,9 @@ struct MochiApp: App { CommandGroup(replacing: .newItem) {} } + #endif + #if os(macOS) Settings { SettingsFeature.View( store: appDelegate.store.scope( @@ -47,7 +66,8 @@ struct MochiApp: App { ) ) .themeable() + .frame(width: 412) } + #endif } } -#endif diff --git a/App/Shared/mochi-info.plist b/App/Shared/mochi-info.plist index 15ae84c..e5e1bbf 100644 --- a/App/Shared/mochi-info.plist +++ b/App/Shared/mochi-info.plist @@ -8,7 +8,7 @@ CFBundleTypeRole Viewer CFBundleURLName - com.errorerrorerror.mochi + dev.errorerrorerror.mochi CFBundleURLSchemes mochi diff --git a/App/iOS/HostingController.swift b/App/iOS/PreferenceHostingController.swift similarity index 71% rename from App/iOS/HostingController.swift rename to App/iOS/PreferenceHostingController.swift index 0434f00..9562056 100644 --- a/App/iOS/HostingController.swift +++ b/App/iOS/PreferenceHostingController.swift @@ -6,13 +6,13 @@ // // -#if os(iOS) +#if canImport(UIKit) import Foundation import SwiftUI import UIKit import ViewComponents -final class HostingController: UIHostingController, OpaqueController { +final class PreferenceHostingController: UIHostingController>, OpaquePreferenceHostingController { override var prefersHomeIndicatorAutoHidden: Bool { _homeIndicatorAutoHidden } var _homeIndicatorAutoHidden = false { @@ -23,7 +23,7 @@ final class HostingController: UIHostingController, Opaq private let box: Box - init(rootView: InnerView) where Variant == BoxedView { + init(rootView: @escaping () -> Root) { self.box = .init() super.init(rootView: .init(box: box, content: rootView)) box.object = self @@ -35,15 +35,15 @@ final class HostingController: UIHostingController, Opaq } } -struct BoxedView: View { +struct BoxedView: View { let box: Box - init(box: Box, content: @autoclosure @escaping () -> Variant) { + init(box: Box, content: @escaping () -> Content) { self.content = content self.box = box } - let content: () -> Variant + let content: () -> Content var body: some View { content() @@ -54,11 +54,11 @@ struct BoxedView: View { } final class Box { - weak var object: OpaqueController? + weak var object: OpaquePreferenceHostingController? } @MainActor -protocol OpaqueController: AnyObject { +protocol OpaquePreferenceHostingController: UIViewController { var _homeIndicatorAutoHidden: Bool { get set } } #endif diff --git a/App/iOS/PreferenceHostingView.swift b/App/iOS/PreferenceHostingView.swift new file mode 100644 index 0000000..3761846 --- /dev/null +++ b/App/iOS/PreferenceHostingView.swift @@ -0,0 +1,100 @@ +// +// PreferenceHostingView.swift +// Mochi +// +// Created by ErrorErrorError on 11/28/23. +// +// Source: https://gist.github.com/Amzd/01e1f69ecbc4c82c8586dcd292b1d30d + +import Foundation +import SwiftUI + +#if canImport(UIKit) +@MainActor +struct PreferenceHostingView: UIViewControllerRepresentable { + init(content: @escaping () -> Content) { + _ = UIViewController.swizzle() + self.content = content + } + + let content: () -> Content + + func makeUIViewController(context: Context) -> PreferenceHostingController { + PreferenceHostingController(rootView: content) + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} +} + +extension UIViewController { + static func swizzle() { + Swizzle(UIViewController.self) { + #selector(getter: childForHomeIndicatorAutoHidden) => #selector(__swizzledChildForHomeIndicatorAutoHidden) + } + } + + @objc func __swizzledChildForHomeIndicatorAutoHidden() -> UIViewController? { + if self is OpaquePreferenceHostingController { + return nil + } else { + return search() + } + } + + private func search() -> OpaquePreferenceHostingController? { + if let result = children.compactMap({ $0 as? OpaquePreferenceHostingController }).first { + return result + } + + for child in children { + if let result = child.search() { + return result + } + } + + return nil + } +} +#endif + +// Move to utils? +struct Swizzle { + @discardableResult + init( + _ type: AnyClass, + @SwizzleSelectorsBuilder builder: () -> [SwizzleReplacer] + ) { + builder().forEach { $0(type) } + } +} + +struct SwizzleReplacer { + let original: Selector + let swizzled: Selector + + func callAsFunction(_ type: AnyClass) { + guard let originalMethod = class_getInstanceMethod(type, original), + let swizzledMethod = class_getInstanceMethod(type, swizzled) else { + return + } + + method_exchangeImplementations(originalMethod, swizzledMethod) + } +} + +@resultBuilder +enum SwizzleSelectorsBuilder { + typealias Component = SwizzleReplacer + + static func buildBlock(_ components: Component...) -> [Component] { + components + } +} + +infix operator => + +extension Selector { + static func => (original: Selector, swizzled: Selector) -> SwizzleReplacer { + .init(original: original, swizzled: swizzled) + } +} diff --git a/App/iOS/SceneDelegate.swift b/App/iOS/SceneDelegate.swift deleted file mode 100644 index d500dba..0000000 --- a/App/iOS/SceneDelegate.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// SceneDelegate.swift -// mochi -// -// Created by ErrorErrorError on 6/27/23. -// -// - -#if os(iOS) -import App -import ComposableArchitecture -import Foundation -import UIKit - -final class SceneDelegate: NSObject, UISceneDelegate { - var window: UIWindow? - - func scene( - _ scene: UIScene, - willConnectTo _: UISceneSession, - options _: UIScene.ConnectionOptions - ) { - window = (scene as? UIWindowScene).flatMap { UIWindow(windowScene: $0) } - window?.rootViewController = HostingController(rootView: AppFeature.View(store: store)) - window?.makeKeyAndVisible() - } -} -#endif diff --git a/Sources/Clients/BuildClient/BuildClient.swift b/Sources/Clients/BuildClient/BuildClient.swift deleted file mode 100644 index 828f681..0000000 --- a/Sources/Clients/BuildClient/BuildClient.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// BuildClient.swift -// -// -// Created by ErrorErrorError on 7/28/23. -// -// - -import Dependencies -import Foundation -import Semver - -// MARK: - BuildClient - -public struct BuildClient { - public var version: Semver - public var buildNumber: Int -} - -// MARK: TestDependencyKey - -extension BuildClient: TestDependencyKey { - public static var testValue: BuildClient = .init( - version: .init(0, 0, 0), - buildNumber: 1 - ) -} - -public extension DependencyValues { - var build: BuildClient { - get { self[BuildClient.self] } - set { self[BuildClient.self] = newValue } - } -} - -// MARK: - BuildClient + DependencyKey - -extension BuildClient: DependencyKey { - public static var liveValue: BuildClient { - .init( - version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") - .flatMap { $0 as? String } - .flatMap { try? Semver($0) } ?? .init(0, 0, 0), - buildNumber: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") - .flatMap { $0 as? Int } ?? 0 - ) - } -} diff --git a/Sources/Clients/BuildClient/Client.swift b/Sources/Clients/BuildClient/Client.swift new file mode 100644 index 0000000..9376712 --- /dev/null +++ b/Sources/Clients/BuildClient/Client.swift @@ -0,0 +1,36 @@ +// +// BuildClient.swift +// +// +// Created by ErrorErrorError on 7/28/23. +// +// + +import Dependencies +import Foundation +import Semver + +// MARK: TestDependencyKey + +public struct BuildKey: DependencyKey { + public static let testValue = Build( + version: .init(0, 0, 0), + number: 0 + ) + + public static let liveValue = Build( + version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") + .flatMap { $0 as? String } + .flatMap { try? Semver($0) } ?? .init(0, 0, 0), + number: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") + .flatMap { $0 as? Int } + .flatMap { .init(rawValue: $0) } ?? .init(0) + ) +} + +public extension DependencyValues { + var build: Build { + get { self[BuildKey.self] } + set { self[BuildKey.self] = newValue } + } +} diff --git a/Sources/Clients/BuildClient/Model.swift b/Sources/Clients/BuildClient/Model.swift new file mode 100644 index 0000000..7e99421 --- /dev/null +++ b/Sources/Clients/BuildClient/Model.swift @@ -0,0 +1,20 @@ +// +// Build.swift +// +// +// Created by ErrorErrorError on 11/27/23. +// +// + +import Foundation +import Semver +import Tagged + +public struct Build: Equatable, Sendable { + public let version: Semver + public let number: Number + + public typealias Number = Tagged<((), number: ()), Int> +} + +extension Semver: @unchecked Sendable {} diff --git a/Sources/Clients/LoggerClient/Client.swift b/Sources/Clients/LoggerClient/Client.swift index 5af179d..b9b7515 100644 --- a/Sources/Clients/LoggerClient/Client.swift +++ b/Sources/Clients/LoggerClient/Client.swift @@ -6,30 +6,51 @@ // Copyright © 2023. All rights reserved. // +import ComposableArchitecture import Dependencies import Foundation -import OSLog +import Logging import XCTestDynamicOverlay -// MARK: - LoggerClientKey +// Global App Logger +public let logger = Logger(label: "dev.errorerrorerror.mochi.app") -public struct LoggerClientKey: DependencyKey { - public static var previewValue = Logger(category: "preview") - public static var liveValue = Logger() - public static let testValue = Logger(category: "test") -} +// TODO: Allow viewing logs using logger client +public struct LoggerClient {} + +// MARK: - LoggerClientKey -public extension DependencyValues { +extension DependencyValues { var logger: Logger { - get { self[LoggerClientKey.self] } - set { self[LoggerClientKey.self] = newValue } + get { self[Logger.self] } + set { self[Logger.self] = newValue } } } +extension Logger: DependencyKey { + public static var previewValue: Logger { Logger(label: "debug") } + public static var testValue: Logger { Logger(label: "test") } + public static var liveValue: Logger { logger } +} + extension Logger { - private static var subsystem = Bundle.main.bundleIdentifier.unsafelyUnwrapped + // TODO: Add support for viewing logs when toggled. This should stay in memory only. + subscript(reducer reducer: T.Type) -> Logger { + .init(label: String(describing: reducer)) + } +} - init(category: String) { - self.init(subsystem: Self.subsystem, category: category) +public extension _ReducerPrinter { + static func swiftLogger(_ reducerType: R.Type = R.self) -> Self where R.State == State, R.Action == Action { + Self { receivedAction, oldState, newState in + var target = "" + target.write("received action:\n") + CustomDump.customDump(receivedAction, to: &target, indent: 2) + target.write("\n") + target.write(diff(oldState, newState).map { "\($0)\n" } ?? " (No state changes)\n") + @Dependency(\.logger) + var logger + logger[reducer: reducerType].debug(.init(stringLiteral: target)) + } } } diff --git a/Sources/Clients/LoggerClient/Live.swift b/Sources/Clients/LoggerClient/Live.swift new file mode 100644 index 0000000..674ce1b --- /dev/null +++ b/Sources/Clients/LoggerClient/Live.swift @@ -0,0 +1,9 @@ +// +// File.swift +// +// +// Created by ErrorErrorError on 11/27/23. +// +// + +import Foundation diff --git a/Sources/Clients/ModuleClient/Client.swift b/Sources/Clients/ModuleClient/Client.swift index 2fb4d2e..c274348 100644 --- a/Sources/Clients/ModuleClient/Client.swift +++ b/Sources/Clients/ModuleClient/Client.swift @@ -16,7 +16,7 @@ import XCTestDynamicOverlay public struct ModuleClient: Sendable { public var initialize: @Sendable () async throws -> Void - public var getModule: @Sendable (_ repoModuleId: RepoModuleID) async throws -> Self.Instance + var getModule: @Sendable (_ repoModuleId: RepoModuleID) async throws -> Self.Instance public var removeCachedModule: @Sendable (_ repoModuleId: RepoModuleID) async throws -> Void public var removeCachedModules: @Sendable (_ repoID: Repo.ID) async throws -> Void } diff --git a/Sources/Clients/ModuleClient/Extensions/Logger+.swift b/Sources/Clients/ModuleClient/Extensions/Logger+.swift new file mode 100644 index 0000000..05917ea --- /dev/null +++ b/Sources/Clients/ModuleClient/Extensions/Logger+.swift @@ -0,0 +1,20 @@ +// +// Logging.swift +// +// +// Created by ErrorErrorError on 11/27/23. +// +// + +import Foundation +import Logging + +struct ModuleLoggerHandler: LogHandler { + var metadata: Logger.Metadata = .init() + var logLevel: Logger.Level = .info + + subscript(metadataKey key: String) -> Logger.Metadata.Value? { + get { metadata[key] } + set { metadata[key] = newValue } + } +} diff --git a/Sources/Clients/ModuleClient/Instance.swift b/Sources/Clients/ModuleClient/Instance.swift index 17099bc..3afd52c 100644 --- a/Sources/Clients/ModuleClient/Instance.swift +++ b/Sources/Clients/ModuleClient/Instance.swift @@ -8,22 +8,24 @@ import Foundation import JavaScriptCore -import os +import Logging import SharedModels public extension ModuleClient { struct Instance { + private let id: RepoModuleID private let module: Module private let runtime: any JSRuntime private let logger: Logger - init(module: Module) throws { + init(id: RepoModuleID, module: Module) throws { + self.id = id self.module = module - self.logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.errorerrorerror.mochi", category: "module-\(module.id.rawValue)") + self.logger = Logger(label: id.description) self.runtime = try JSContext(module) { [logger] type, msg in switch type { case .log: - logger.log("\(msg)") + logger.log(level: .info, "\(msg)") case .debug: logger.debug("\(msg)") case .error: @@ -38,17 +40,6 @@ public extension ModuleClient { } } -extension ModuleClient.Instance { - private func reportError(_ callback: @autoclosure @escaping () async throws -> R) async rethrows -> R { - do { - return try await callback() - } catch { - self.logger.error("\(error)") - throw error - } - } -} - /// Available SourceModule Methods public extension ModuleClient.Instance { func searchFilters() async throws -> [SearchFilter] { @@ -82,3 +73,14 @@ public extension ModuleClient.Instance { try await reportError(await runtime.playlistEpisodeServer(request)) } } + +extension ModuleClient.Instance { + private func reportError(_ callback: @autoclosure () async throws -> R) async rethrows -> R { + do { + return try await callback() + } catch { + self.logger.error("\(error)") + throw error + } + } +} diff --git a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Console.swift b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Console.swift index e20a007..7de8a77 100644 --- a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Console.swift +++ b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Console.swift @@ -9,8 +9,16 @@ import Foundation import JavaScriptCore +enum JSMessageLog: String, CaseIterable { + case log + case debug + case error + case info + case warn +} + extension JSContext { - func setConsoleBinding(_ logger: @escaping (MessageLog, String) -> Void) { + func setConsoleBinding(_ logger: @escaping (JSMessageLog, String) -> Void) { exceptionHandler = { _, exception in guard let exception else { return @@ -21,7 +29,7 @@ extension JSContext { let console = JSValue(newObjectIn: self) - let logger = { (type: MessageLog) in { + let logger = { (type: JSMessageLog) in { guard let arguments = JSContext.currentArguments()?.compactMap({ $0 as? JSValue }) else { return } @@ -32,7 +40,7 @@ extension JSContext { logger(type, msg) } as @convention(block) () -> Void } - MessageLog.allCases.forEach { console?.setObject(logger($0), forKeyedSubscript: $0.rawValue) } + JSMessageLog.allCases.forEach { console?.setObject(logger($0), forKeyedSubscript: $0.rawValue) } setObject(console, forKeyedSubscript: "console" as NSString) } diff --git a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift index 01aab5b..c0e3588 100644 --- a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift +++ b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift @@ -11,11 +11,10 @@ import FileClient import Foundation import JavaScriptCore import JSValueCoder -import os import SharedModels extension JSContext { - convenience init(_ module: Module, _ logger: @escaping (MessageLog, String) -> Void) throws { + convenience init(_ module: Module, _ logger: @escaping (JSMessageLog, String) -> Void) throws { self.init() setConsoleBinding(logger) @@ -46,34 +45,26 @@ extension JSContext: JSRuntime { func invokeInstanceMethod(functionName: String, args: [Encodable]) throws { let function = try getFunctionInstance(functionName) - let encoder = JSValueEncoder() - try function.call(withArguments: args.map { try encoder.encode($0, into: self) }) } func invokeInstanceMethodWithPromise(functionName: String, args: [Encodable]) async throws { let function = try getFunctionInstance(functionName) - let encoder = JSValueEncoder() - guard let promise = try function.call(withArguments: args.map { try encoder.encode($0, into: self) }) else { throw ModuleClient.Error.jsRuntime(.promiseValueError) } - try await promise.value(functionName) } func invokeInstanceMethodWithPromise(functionName: String, args: [Encodable]) async throws -> T { let function = try getFunctionInstance(functionName) - let encoder = JSValueEncoder() let decoder = JSValueDecoder() - guard let promise = try function.call(withArguments: args.map { try encoder.encode($0, into: self) }) else { throw ModuleClient.Error.jsRuntime(.instanceCall(function: "Instance.\(functionName)", msg: "Failed to retrieve value from function")) } - return try await decoder.decode(T.self, from: promise.value(functionName)) } @@ -86,12 +77,10 @@ extension JSContext: JSRuntime { private func getFunctionInstance(_ functionName: String) throws -> JSValue { let instance = try getInstance() - // Function is a form of an object guard let function = instance[functionName], function.isObject else { throw ModuleClient.Error.jsRuntime(.instanceCall(function: functionName, msg: "this function does not exist")) } - return function } } diff --git a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift index 8d687d7..7db6b15 100644 --- a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift +++ b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+Request.swift @@ -132,7 +132,7 @@ extension JSContext { defer { JSStringRelease(value) } return JSValue( jsValueRef: JSValueMakeFromJSONString(ctx?.jsGlobalContextRef, value) ?? - JSValueMakeUndefined(ctx?.jsGlobalContextRef) , + JSValueMakeUndefined(ctx?.jsGlobalContextRef), in: ctx ) } as @convention(block) () -> JSValue diff --git a/Sources/Clients/ModuleClient/JS+Bindings/MessageLog.swift b/Sources/Clients/ModuleClient/JS+Bindings/MessageLog.swift deleted file mode 100644 index 5ec26fc..0000000 --- a/Sources/Clients/ModuleClient/JS+Bindings/MessageLog.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// File.swift -// -// -// Created by ErrorErrorError on 11/6/23. -// -// - -import Foundation - -enum MessageLog: String, CaseIterable { - case log - case debug - case error - case info - case warn -} diff --git a/Sources/Clients/ModuleClient/Live.swift b/Sources/Clients/ModuleClient/Live.swift index 8ba9dc0..f66ff86 100644 --- a/Sources/Clients/ModuleClient/Live.swift +++ b/Sources/Clients/ModuleClient/Live.swift @@ -64,7 +64,7 @@ private actor ModulesCache { throw ModuleClient.Error.client(.moduleNotFound) } - let instance = try ModuleClient.Instance(module: module) + let instance = try ModuleClient.Instance(id: id, module: module) cached[id] = instance return instance diff --git a/Sources/Clients/UserSettingsClient/Live.swift b/Sources/Clients/UserSettingsClient/Live.swift index 7772883..65dbc8b 100644 --- a/Sources/Clients/UserSettingsClient/Live.swift +++ b/Sources/Clients/UserSettingsClient/Live.swift @@ -21,6 +21,7 @@ extension UserSettingsClient: DependencyKey { userSettings.withValue { state in state = newValue subject.send(newValue) + print("Save settings") } } save: { // TODO: Save UserSettingsClient diff --git a/Sources/Clients/UserSettingsClient/Theme.swift b/Sources/Clients/UserSettingsClient/Theme.swift index bb7201f..e56c63c 100644 --- a/Sources/Clients/UserSettingsClient/Theme.swift +++ b/Sources/Clients/UserSettingsClient/Theme.swift @@ -11,6 +11,7 @@ import SwiftUI import Tagged import ViewComponents +// Should be a struct instead? public enum Theme: Codable, Sendable, Hashable, Identifiable, CaseIterable { public var id: Tagged { .init(hashValue) } @@ -57,11 +58,19 @@ public enum Theme: Codable, Sendable, Hashable, Identifiable, CaseIterable { blue: 0xF7 / 0xFF ) case .dark: + #if os(macOS) + .init( + red: 0x1A / 0xFF, + green: 0x1A / 0xFF, + blue: 0x1A / 0xFF + ) + #else .init( red: 0x0A / 0xFF, green: 0x0A / 0xFF, blue: 0x0A / 0xFF ) + #endif } } @@ -90,3 +99,9 @@ public enum Theme: Codable, Sendable, Hashable, Identifiable, CaseIterable { } } } + +public extension Theme { + static let pastelGreen = Color(hue: 138 / 360, saturation: 0.33, brightness: 0.63) + static let pastelBlue = Color(hue: 178 / 360, saturation: 0.39, brightness: 0.7) + static let pastelOrange = Color(hue: 27 / 360, saturation: 0.41, brightness: 0.69) +} diff --git a/Sources/Clients/UserSettingsClient/UserSettings.swift b/Sources/Clients/UserSettingsClient/UserSettings.swift index cff1659..d91c268 100644 --- a/Sources/Clients/UserSettingsClient/UserSettings.swift +++ b/Sources/Clients/UserSettingsClient/UserSettings.swift @@ -9,12 +9,15 @@ public struct UserSettings: Sendable, Equatable, Codable { public var theme: Theme public var appIcon: AppIcon + public var developerModeEnabled: Bool public init( theme: Theme = .automatic, - appIcon: AppIcon = .default + appIcon: AppIcon = .default, + developerModeEnabled: Bool = false ) { self.theme = theme self.appIcon = appIcon + self.developerModeEnabled = developerModeEnabled } } diff --git a/Sources/Features/App/AppFeature+Reducer.swift b/Sources/Features/App/AppFeature+Reducer.swift index 0c7bb61..31327d3 100644 --- a/Sources/Features/App/AppFeature+Reducer.swift +++ b/Sources/Features/App/AppFeature+Reducer.swift @@ -30,14 +30,10 @@ extension AppFeature: Reducer { if state.selected == tab { switch tab { case .discover: - if !state.discover.screens.isEmpty { - state.discover.screens.removeAll() - } else if state.discover.isSearchExpanded { - return state.discover.collapseSearch() - .map { .internal(.discover($0)) } - } else if !state.discover.search.query.isEmpty { - return state.discover.collapseAndClearSearch() - .map { .internal(.discover($0)) } + if !state.discover.path.isEmpty { + state.discover.path.removeAll() + } else if state.discover.search != nil { + state.discover.search = nil } case .repos: state.repos.path.removeAll() diff --git a/Sources/Features/App/AppFeature.swift b/Sources/Features/App/AppFeature.swift index 7289c41..dc0e88b 100644 --- a/Sources/Features/App/AppFeature.swift +++ b/Sources/Features/App/AppFeature.swift @@ -73,11 +73,11 @@ public struct AppFeature: Feature { var colorAccent: Color { switch self { case .discover: - .init(hue: 138 / 360, saturation: 0.33, brightness: 0.63) + Theme.pastelGreen case .repos: - .init(hue: 178 / 360, saturation: 0.39, brightness: 0.7) + Theme.pastelBlue case .settings: - .init(hue: 27 / 360, saturation: 0.41, brightness: 0.69) + Theme.pastelOrange } } } @@ -116,7 +116,8 @@ public struct AppFeature: Feature { @Environment(\.theme) var theme - public nonisolated init(store: StoreOf) { + @MainActor + public init(store: StoreOf) { self.store = store } } diff --git a/Sources/Features/App/macOS/AppFeatureView+macOS.swift b/Sources/Features/App/macOS/AppFeatureView+macOS.swift index be2c6c2..d847d61 100644 --- a/Sources/Features/App/macOS/AppFeatureView+macOS.swift +++ b/Sources/Features/App/macOS/AppFeatureView+macOS.swift @@ -30,24 +30,28 @@ extension AppFeature.View: View { send: .view(.didSelectTab(tab)) ) ) { - switch tab { - case .discover: - DiscoverFeature.View( - store: store.scope( - state: \.discover, - action: Action.InternalAction.discover + Group { + switch tab { + case .discover: + DiscoverFeature.View( + store: store.scope( + state: \.discover, + action: Action.InternalAction.discover + ) ) - ) - case .repos: - ReposFeature.View( - store: store.scope( - state: \.repos, - action: Action.InternalAction.repos + case .repos: + ReposFeature.View( + store: store.scope( + state: \.repos, + action: Action.InternalAction.repos + ) ) - ) - case .settings: - EmptyView() + case .settings: + EmptyView() + } } + // FIXME: Set max width for inside scroll view to show scrollbar to the edge + .frame(maxWidth: 1_280) } label: { Label(tab.rawValue, systemImage: tab.image) } diff --git a/Sources/Features/ContentCore/ContentCore.swift b/Sources/Features/ContentCore/ContentCore.swift index 05ce68f..9451ccc 100644 --- a/Sources/Features/ContentCore/ContentCore.swift +++ b/Sources/Features/ContentCore/ContentCore.swift @@ -189,9 +189,6 @@ public extension ContentCore.State { @Dependency(\.moduleClient) var moduleClient - @Dependency(\.logger) - var logger - let playlistId = self.playlist.id let repoModuleId = self.repoModuleId diff --git a/Sources/Features/Discover/DiscoverFeature+Reducer.swift b/Sources/Features/Discover/DiscoverFeature+Reducer.swift index 07a6a4c..9967411 100644 --- a/Sources/Features/Discover/DiscoverFeature+Reducer.swift +++ b/Sources/Features/Discover/DiscoverFeature+Reducer.swift @@ -34,41 +34,37 @@ extension DiscoverFeature { state.moduleLists = .init() case let .view(.didTapPlaylist(playlist)): - guard case let .module(moduleState) = state.selected else { + guard let id = state.section.module?.module.id else { break } - let repoModuleId = moduleState.module.id - state.screens.append(.playlistDetails(.init(content: .init(repoModuleId: repoModuleId, playlist: playlist)))) + state.path.append(.playlistDetails(.init(content: .init(repoModuleId: id, playlist: playlist)))) + + case .view(.didTapSearchButton): + state.search = SearchFeature.State( + searchFieldFocused: true, + repoModuleId: state.section.module?.module.id + ) case let .internal(.selectedModule(selection)): if let selection { - state.selected = .module(.init(module: selection, listings: .pending)) + state.section = .module(.init(module: selection, listings: .pending)) } else { - state.selected = .home() + state.section = .home() } - return .merge( - state.search.updateModule(with: selection?.id).map { .internal(.search($0)) }, - state.fetchLatestListings(selection) - ) + return state.fetchLatestListings(selection) case let .internal(.loadedListings(id, loadable)): - if case var .module(moduleState) = state.selected, moduleState.module.repoId == id.repoId, moduleState.module.module.id == id.moduleId { + if var moduleState = state.section.module, moduleState.module.repoId == id.repoId, moduleState.module.module.id == id.moduleId { moduleState.listings = loadable - state.selected = .module(moduleState) + state.section = .module(moduleState) } case let .internal(.moduleLists(.presented(.delegate(.selectedModule(repoModule))))): state.moduleLists = nil return .send(.internal(.selectedModule(repoModule))) - case let .internal(.search(.delegate(.playlistTapped(repoModuleId, playlist)))): - state.screens.append(.playlistDetails(.init(content: .init(repoModuleId: repoModuleId, playlist: playlist)))) - - case .internal(.search): - break - - case .internal(.moduleLists): - break + case let .internal(.search(.presented(.delegate(.playlistTapped(repoModuleId, playlist))))): + state.path.append(.playlistDetails(.init(content: .init(repoModuleId: repoModuleId, playlist: playlist)))) case let .internal(.screens(.element(_, .playlistDetails(.delegate(.playbackVideoItem(items, id, playlist, group, variant, paging, itemId)))))): return .send( @@ -85,6 +81,12 @@ extension DiscoverFeature { ) ) + case .internal(.moduleLists): + break + + case .internal(.search): + break + case .internal(.screens): break @@ -96,13 +98,12 @@ extension DiscoverFeature { .ifLet(\.$moduleLists, action: \.internal.moduleLists) { ModuleListsFeature() } - .forEach(\.screens, action: \.internal.screens) { - DiscoverFeature.Screens() - } - - Scope(state: \.search, action: \.internal.search) { + .ifLet(\.$search, action: \.internal.search) { SearchFeature() } + .forEach(\.path, action: \.internal.screens) { + DiscoverFeature.Path() + } } } @@ -112,11 +113,12 @@ extension DiscoverFeature.State { var moduleClient guard let selectedModule else { - selected = .home(.init()) + section = .home(.init()) return .none } - selected = .module(.init(module: selectedModule, listings: .loading)) + section = .module(.init(module: selectedModule, listings: .loading)) + let id = selectedModule.id return .run { send in diff --git a/Sources/Features/Discover/DiscoverFeature+View.swift b/Sources/Features/Discover/DiscoverFeature+View.swift index 11a3e9d..419a7ec 100644 --- a/Sources/Features/Discover/DiscoverFeature+View.swift +++ b/Sources/Features/Discover/DiscoverFeature+View.swift @@ -7,6 +7,8 @@ // import Architecture +@_spi(Presentation) +import ComposableArchitecture import ModuleLists import Nuke import NukeUI @@ -24,134 +26,119 @@ extension DiscoverFeature.View: View { public var body: some View { NavStack( store.scope( - state: \.screens, + state: \.path, action: Action.InternalAction.screens ) ) { - ZStack(alignment: .bottom) { - WithViewStore(store, observe: \.selected) { viewStore in - ZStack { - switch viewStore.state { - case .home: - // TODO: Create home listing - VStack { - Spacer() - Text("Coming soon!") - Spacer() - } - case let .module(moduleState): - buildModuleView(moduleState: moduleState) + WithViewStore(store, observe: \.section) { viewStore in + ZStack { + switch viewStore.state { + case .home: + // TODO: Create home listing + VStack { + Spacer() + Text("Coming soon!") + Spacer() } + case let .module(moduleState): + buildModuleView(moduleState: moduleState) } - .animation(.easeInOut(duration: 0.25), value: viewStore.state) - #if os(iOS) - .safeAreaInset(edge: .top) { - TopBarView( - backgroundStyle: .gradient(), - leadingAccessory: { - Button { - viewStore.send(.didTapOpenModules) - } label: { - HStack(spacing: 8) { - if let url = viewStore.icon { - LazyImage(url: url) { state in - if let image = state.image { - image - .resizable() - .scaledToFit() - .frame(width: 22, height: 22) - } else { - EmptyView() - } - } - .transition(.opacity) + } + .animation(.easeInOut(duration: 0.25), value: viewStore.state) + #if os(iOS) + .topBar( + backgroundStyle: .gradient(), + leadingAccessory: { + Button { + viewStore.send(.didTapOpenModules) + } label: { + HStack(spacing: 8) { + if let url = viewStore.icon { + LazyImage(url: url) { state in + if let image = state.image { + image + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + } else { + EmptyView() } - - Text(viewStore.title) - Image(systemName: "chevron.down") - .font(.body.weight(.bold)) - Spacer() } - .font(.title.bold()) - .contentShape(Rectangle()) - .scaleEffect(1.0) .transition(.opacity) - .animation(.easeInOut, value: viewStore.icon) } - .buttonStyle(.plain) + + Text(viewStore.title) + Image(systemName: "chevron.down") + .font(.body.weight(.bold)) + Spacer() } - ) - .frame(maxWidth: .infinity) - } - #elseif os(macOS) - .navigationTitle("") - .toolbar { - ToolbarItem(placement: .navigation) { - Button { - viewStore.send(.didTapOpenModules) - } label: { - HStack(spacing: 8) { - if let url = viewStore.icon { - LazyImage(url: url) { state in - if let image = state.image { - image - .resizable() - .scaledToFit() - .frame(width: 22, height: 22) - } else { - EmptyView() - } + .font(.title.bold()) + .contentShape(Rectangle()) + .scaleEffect(1.0) + .transition(.opacity) + .animation(.easeInOut, value: viewStore.icon) + } + .buttonStyle(.plain) + }, + trailingAccessory: { + Button { + viewStore.send(.didTapSearchButton) + } label: { + Image(systemName: "magnifyingglass") + } + .buttonStyle(.materialToolbarImage) + .transition(.slide) + .matchedGeometryEffect(id: "Search", in: searchAnimation) + }, + bottomAccessory: EmptyView.init + ) + #elseif os(macOS) + .navigationTitle("") + .toolbar { + ToolbarItem(placement: .navigation) { + Button { + viewStore.send(.didTapOpenModules) + } label: { + HStack(spacing: 8) { + if let url = viewStore.icon { + LazyImage(url: url) { state in + if let image = state.image { + image + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + } else { + EmptyView() } - .transition(.opacity) } + .transition(.opacity) + } - Text(viewStore.title) - .font(.title3.bold()) + Text(viewStore.title) + .font(.title3.bold()) - Image(systemName: "chevron.down") - .font(.system(size: 1).weight(.semibold)) - } - .contentShape(Rectangle()) - .scaleEffect(1.0) - .transition(.opacity) - .animation(.easeInOut, value: viewStore.icon) + Image(systemName: "chevron.down") + .font(.system(size: 1).weight(.semibold)) } - .buttonStyle(.bordered) + .contentShape(Rectangle()) + .scaleEffect(1.0) + .transition(.opacity) + .animation(.easeInOut, value: viewStore.icon) } + .buttonStyle(.bordered) + } - ToolbarItem(placement: .automatic) { - WithViewStore( - store.scope( - state: \.search, - action: Action.InternalAction.search - ), - observe: \.`self` - ) { viewStore in - TextField("Search for Content", text: viewStore.$query) - .textFieldStyle(.roundedBorder) - .frame(minWidth: 200) - } + ToolbarItem(placement: .automatic) { + Button { + viewStore.send(.didTapSearchButton) + } label: { + Image(systemName: "magnifyingglass") } } - #endif - .safeAreaInset(edge: .bottom) { - Spacer() - .frame(height: searchBarSize.height) - } } - .zIndex(1) - - SearchFeature.View( - store: store.scope( - state: \.search, - action: Action.InternalAction.search - ) - ) - .onSearchBarSizeChanged { size in - searchBarSize = size - } - .zIndex(2) + #endif } + .ignoresSafeArea(.keyboard) .frame( maxWidth: .infinity, maxHeight: .infinity @@ -163,19 +150,32 @@ extension DiscoverFeature.View: View { action: { .internal(.moduleLists($0)) } ) ) - .safeInset(from: \.bottomNavigation, edge: .bottom) + .presentation( + store: store.scope( + state: \.$search, + action: Action.InternalAction.search + ) + ) { (content, binding: Binding, destination) in + ZStack { + if binding.wrappedValue { + destination({ SearchFeature.View(store: $0, namespace: searchAnimation) }) + } else { + content + } + } + .animation(.interactiveSpring(), value: binding.wrappedValue) + } } destination: { store in SwitchStore(store) { state in switch state { case .playlistDetails: CaseLet( - /DiscoverFeature.Screens.State.playlistDetails, - action: DiscoverFeature.Screens.Action.playlistDetails, - then: PlaylistDetailsFeature.View.init + /DiscoverFeature.Path.State.playlistDetails, + action: DiscoverFeature.Path.Action.playlistDetails, + then: { store in PlaylistDetailsFeature.View(store: store) } ) } } - .safeInset(from: \.bottomNavigation, edge: .bottom, alignment: .center) } } } @@ -402,7 +402,7 @@ extension DiscoverFeature.View { alignment: .top, spacing: 20, edgeInsets: .init(trailing: 40), - items: Array(0...sections) + items: Array(0 ... sections) ) { col in let start = col * rowCount let end = start + min(rowCount, listing.items.count - start) @@ -464,13 +464,15 @@ extension DiscoverFeature.View { func featuredListing(_ listing: DiscoverListing) -> some View { VStack { HStack { - Text(listing.title) + Text(listing.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "No Title" : listing.title) .font(.title3.weight(.semibold)) Spacer() if listing.paging.nextPage != nil { - Button {} label: { + Button { + // TODO: open new view to show all pagings + } label: { Text("Show All") .font(.footnote.weight(.bold)) .foregroundColor(.gray) @@ -482,42 +484,51 @@ extension DiscoverFeature.View { .padding(.horizontal) // TODO: Make size based on listing's size type - SnapScroll( - alignment: .center, - spacing: 8, - edgeInsets: .init(leading: 8, trailing: 8), - items: listing.items - ) { playlist in - ZStack(alignment: .bottom) { - FillAspectImage(url: playlist.bannerImage ?? playlist.posterImage) - .overlay { - LinearGradient( - gradient: .init( - colors: [ - .black.opacity(0), - .black.opacity(0.4) - ], - easing: .easeIn - ), - startPoint: .top, - endPoint: .bottom - ) - } + // TODO: Make it snap for devices lower than iOS 17 (other platforms too) + // TODO: Show indicators for macOS + GeometryReader { proxy in + let maxWidthPerItem = proxy.size.width * 0.8 + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + ForEach(listing.items) { playlist in + ZStack(alignment: .bottom) { + FillAspectImage(url: playlist.posterImage ?? playlist.bannerImage) + // TODO: Make gradient with blur + .overlay { + LinearGradient( + gradient: .init( + colors: [ + .black.opacity(0), + .black.opacity(0.4) + ], + easing: .easeIn + ), + startPoint: .top, + endPoint: .bottom + ) + } - Text(playlist.title ?? "No Title") - .font(.title2.weight(.medium)) - .foregroundColor(.white) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - .padding(.bottom) - } - .cornerRadius(12) - .onTapGesture { - store.send(.view(.didTapPlaylist(playlist))) + Text(playlist.title ?? "No Title") + .font(.title2.weight(.medium)) + .foregroundColor(.white) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.bottom) + } + .cornerRadius(12) + .onTapGesture { + store.send(.view(.didTapPlaylist(playlist))) + } + .frame(width: maxWidthPerItem) + .frame(maxHeight: .infinity) + } + } + .padding(.horizontal) } + .frame(maxWidth: .infinity) } - .aspectRatio(6 / 7, contentMode: .fill) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .aspectRatio(listing.orientation == .portrait ? 6 / 7 : 16 / 10, contentMode: .fit) } } } @@ -527,8 +538,8 @@ extension DiscoverFeature.View { #Preview { DiscoverFeature.View( store: .init( - initialState: .init(selected: .home()), - reducer: { EmptyReducer() } + initialState: .init(section: .home()), + reducer: { DiscoverFeature() } ) ) } diff --git a/Sources/Features/Discover/DiscoverFeature.swift b/Sources/Features/Discover/DiscoverFeature.swift index 5087850..6c0969a 100644 --- a/Sources/Features/Discover/DiscoverFeature.swift +++ b/Sources/Features/Discover/DiscoverFeature.swift @@ -44,7 +44,7 @@ public struct DiscoverFeature: Feature { } } - public struct Screens: Reducer { + public struct Path: Reducer { public enum State: Equatable, Sendable { case playlistDetails(PlaylistDetailsFeature.State) } @@ -60,6 +60,8 @@ public struct DiscoverFeature: Feature { } } + @CasePathable + @dynamicMemberLookup public enum Section: Equatable, Sendable { case home(HomeState = .init()) case module(ModuleListingState) @@ -93,21 +95,23 @@ public struct DiscoverFeature: Feature { } public struct State: FeatureState { - public var selected: Section - public var screens: StackState - public var search: SearchFeature.State + public var section: Section + public var path: StackState + + @PresentationState + public var search: SearchFeature.State? @PresentationState public var moduleLists: ModuleListsFeature.State? public init( - selected: DiscoverFeature.Section = .home(), - screens: StackState = .init(), - search: SearchFeature.State = .init(), + section: DiscoverFeature.Section = .home(), + screens: StackState = .init(), + search: SearchFeature.State? = nil, moduleLists: ModuleListsFeature.State? = nil ) { - self.selected = selected - self.screens = screens + self.section = section + self.path = screens self.search = search self.moduleLists = moduleLists } @@ -120,6 +124,7 @@ public struct DiscoverFeature: Feature { case didAppear case didTapOpenModules case didTapPlaylist(Playlist) + case didTapSearchButton } @CasePathable @@ -139,9 +144,9 @@ public struct DiscoverFeature: Feature { public enum InternalAction: SendableAction { case selectedModule(RepoClient.SelectedModule?) case loadedListings(RepoModuleID, Loadable<[DiscoverListing]>) - case screens(StackAction) + case screens(StackAction) case moduleLists(PresentationAction) - case search(SearchFeature.Action) + case search(PresentationAction) } case view(ViewAction) @@ -153,10 +158,11 @@ public struct DiscoverFeature: Feature { public struct View: FeatureView { public let store: StoreOf - @SwiftUI.State - var searchBarSize = CGSize.zero + @Namespace + public var searchAnimation - public nonisolated init(store: StoreOf) { + @MainActor + public init(store: StoreOf) { self.store = store } } @@ -171,18 +177,8 @@ public struct DiscoverFeature: Feature { } public extension DiscoverFeature.State { - var isSearchExpanded: Bool { - search.expandView - } - - mutating func collapseSearch() -> Effect { - search.collapse().map { .internal(.search($0)) } - } - - mutating func collapseAndClearSearch() -> Effect { - .concatenate( - search.collapse().map { .internal(.search($0)) }, - search.clearQuery().map { .internal(.search($0)) } - ) + mutating func clearQuery() -> Effect { + self.search?.clearQuery() + .map { .internal(.search(.presented($0))) } ?? .none } } diff --git a/Sources/Features/ModuleLists/ModuleListsFeature+View.swift b/Sources/Features/ModuleLists/ModuleListsFeature+View.swift index bab0c90..9b54d56 100644 --- a/Sources/Features/ModuleLists/ModuleListsFeature+View.swift +++ b/Sources/Features/ModuleLists/ModuleListsFeature+View.swift @@ -73,7 +73,15 @@ extension ModuleListsFeature.View { Text(repo.author) .font(.subheadline.bold()) - .foregroundColor(.gray) + .opacity(0.8) + + Spacer() + .frame(height: 4) + .fixedSize(horizontal: false, vertical: true) + + Text(repo.id.displayIdentifier) + .font(.footnote) + .foregroundColor(.gray.opacity(0.8)) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -134,6 +142,10 @@ extension ModuleListsFeature.View { Text(module.name) .font(.body.weight(.medium)) +// Text(module.id.rawValue) +// .font(.footnote.weight(.medium)) +// .foregroundColor(.gray) +// Text("v\(module.version.description)") .font(.footnote.weight(.medium)) .foregroundColor(.gray) @@ -147,6 +159,7 @@ extension ModuleListsFeature.View { } public extension View { + @MainActor func moduleListsSheet( _ store: Store, PresentationAction> ) -> some View { diff --git a/Sources/Features/ModuleLists/ModuleListsFeature.swift b/Sources/Features/ModuleLists/ModuleListsFeature.swift index 469023f..ac20902 100644 --- a/Sources/Features/ModuleLists/ModuleListsFeature.swift +++ b/Sources/Features/ModuleLists/ModuleListsFeature.swift @@ -67,7 +67,8 @@ public struct ModuleListsFeature: Feature { public struct View: FeatureView { public let store: StoreOf - public nonisolated init(store: StoreOf) { + @MainActor + public init(store: StoreOf) { self.store = store } } diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift index 3838807..27f878c 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature+Reducer.swift @@ -108,9 +108,6 @@ extension PlaylistDetailsFeature.State { @Dependency(\.moduleClient) var moduleClient - @Dependency(\.logger) - var logger - var effects = [Effect]() let playlistId = playlist.id diff --git a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift index 5141178..5abc4a9 100644 --- a/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift +++ b/Sources/Features/PlaylistDetails/PlaylistDetailsFeature.swift @@ -148,16 +148,14 @@ public struct PlaylistDetailsFeature: Feature { @Environment(\.openURL) var openURL - @InsetValue(\.bottomNavigation) - var bottomNavigationSize - @SwiftUI.State var imageDominatColor: Color? @Environment(\.theme) var theme - public nonisolated init(store: StoreOf) { + @MainActor + public init(store: StoreOf) { self.store = store } } @@ -171,9 +169,6 @@ public struct PlaylistDetailsFeature: Feature { @Dependency(\.repoClient) var repoClient - @Dependency(\.logger) - var logger - @Dependency(\.dismiss) var dismiss diff --git a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature.swift b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature.swift index 1a43ae9..dd9083f 100644 --- a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature.swift +++ b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature.swift @@ -98,7 +98,8 @@ public extension RepoPackagesFeature { @Environment(\.theme) var theme - public nonisolated init(store: StoreOf) { + @MainActor + public init(store: StoreOf) { self.store = store } } diff --git a/Sources/Features/Repos/ReposFeature+View.swift b/Sources/Features/Repos/ReposFeature+View.swift index 66c431d..8f42718 100644 --- a/Sources/Features/Repos/ReposFeature+View.swift +++ b/Sources/Features/Repos/ReposFeature+View.swift @@ -262,7 +262,7 @@ extension ReposFeature.View { .font(.callout.weight(.medium)) HStack(spacing: 0) { - Text(repo.remoteURL.host ?? repo.author) + Text(repo.id.displayIdentifier) .font(.footnote) } .lineLimit(1) diff --git a/Sources/Features/Repos/ReposFeature.swift b/Sources/Features/Repos/ReposFeature.swift index d337206..fe2c7be 100644 --- a/Sources/Features/Repos/ReposFeature.swift +++ b/Sources/Features/Repos/ReposFeature.swift @@ -83,7 +83,8 @@ public struct ReposFeature: Feature { @Dependency(\.dateFormatter) var dateFormatter - public nonisolated init(store: StoreOf) { + @MainActor + public init(store: StoreOf) { self.store = store } } diff --git a/Sources/Features/Search/SearchFeature+Reducer.swift b/Sources/Features/Search/SearchFeature+Reducer.swift index c3c8417..d7626ea 100644 --- a/Sources/Features/Search/SearchFeature+Reducer.swift +++ b/Sources/Features/Search/SearchFeature+Reducer.swift @@ -17,6 +17,7 @@ import SharedModels private enum Cancellables: Hashable { case fetchingItemsDebounce + case fetchingSearchFilters } // MARK: - SearchFeature + Reducer @@ -30,7 +31,7 @@ extension SearchFeature: Reducer { Reduce { state, action in switch action { case .view(.didAppear): - break + return state.fetchFilters() case let .view(.didShowNextPageIndicator(pagingId)): guard var value = state.items.value, value[pagingId] == nil else { @@ -42,6 +43,7 @@ extension SearchFeature: Reducer { } let searchQuery = state.query + let searchFilters = state.selectedFilters value[pagingId] = .loading state.items = .loaded(value) @@ -59,6 +61,7 @@ extension SearchFeature: Reducer { try await module.search( .init( query: searchQuery, + filters: searchFilters.map(\.searchQueryFilter), page: pagingId ) ) @@ -72,72 +75,63 @@ extension SearchFeature: Reducer { logger.error("There was an error fetching page w/ id: \(pagingId.rawValue) - \(error.localizedDescription)") } - case .view(.didTapFilterOptions): -// return .send(.delegate(.tappedFilterOptions)) - break - case .view(.didTapClearQuery): return state.clearQuery() - case let .view(.didTapPlaylist(playlist)): - if let repoModuleId = state.repoModuleId { - state.searchFieldFocused = false - return .send(.delegate(.playlistTapped(repoModuleId, playlist))) - } - - case .view(.binding(\.$query)): - guard let selected = state.repoModuleId else { - state.items = .pending - return .cancel(id: Cancellables.fetchingItemsDebounce) - } - - let searchQuery = state.query - - guard !searchQuery.isEmpty else { - state.items = .pending - return .cancel(id: Cancellables.fetchingItemsDebounce) - } + case .view(.didTapClearFilters): + state.selectedFilters.removeAll() + return state.fetchQuery() - state.items = .loading + case .view(.didTapBackButton): + return .run { await dismiss() } - return .run { send in - try await withTaskCancellation(id: Cancellables.fetchingItemsDebounce, cancelInFlight: true) { - try await Task.sleep(nanoseconds: 1_000_000 * 600) + case let .view(.didTapFilter(filter, option)): + if var storedFilter = state.selectedFilters[id: filter.id] { + if storedFilter.options[id: option.id] == nil { + storedFilter.options.append(option) + } else { + storedFilter.options.removeAll(where: \.id == option.id) + } - await send( - .internal( - .loadedItems( - .init { - try await moduleClient.withModule(id: selected) { module in - try await module.search(.init(query: searchQuery)) - } - } - ) - ) - ) + if storedFilter.options.isEmpty { + state.selectedFilters[id: filter.id] = nil + } else { + state.selectedFilters[id: filter.id] = storedFilter } - } catch: { error, _ in - logger.error("There was an error fetching page \(error.localizedDescription)") + } else { + state.selectedFilters.append( + .init( + id: filter.id, + displayName: filter.displayName, + multiselect: filter.multiselect, + required: filter.required, + options: [option] + ) + ) } - case .view(.binding(\.$searchFieldFocused)): - if state.searchFieldFocused { - state.expandView = true - } + return state.fetchQuery() - case .view(.binding(\.$expandView)): - if state.searchFieldFocused { + case let .view(.didTapPlaylist(playlist)): + if let repoModuleId = state.repoModuleId { state.searchFieldFocused = false + return .send(.delegate(.playlistTapped(repoModuleId, playlist))) } + case .view(.binding(\.$query)): + return state.fetchQuery() + + case .view(.binding(\.$searchFieldFocused)): + break + case .view(.binding): break case let .internal(.loadedSearchFilters(.success(filters))): - state.filters = filters + state.allFilters = filters case .internal(.loadedSearchFilters(.failure)): - state.filters = [] + state.allFilters = [] case let .internal(.loadedItems(loadable)): state.items = loadable.map { [$0.id: .loaded($0)] } @@ -156,12 +150,81 @@ extension SearchFeature: Reducer { } } -public extension SearchFeature.State { - mutating func collapse() -> Effect { - expandView = false - return .none +extension SearchFeature.State { + mutating func fetchFilters() -> Effect { + guard let selected = repoModuleId else { + allFilters = [] + return .none + } + + @Dependency(\.moduleClient) + var moduleClient + + return .run { send in + await withTaskCancellation(id: Cancellables.fetchingSearchFilters) { + await send( + .internal( + .loadedSearchFilters( + .init { + try await moduleClient.withModule(id: selected) { instance in + try await instance.searchFilters() + } + } + ) + ) + ) + } + } } + mutating func fetchQuery() -> Effect { + guard let selected = repoModuleId else { + items = .pending + return .cancel(id: Cancellables.fetchingItemsDebounce) + } + + let searchQuery = query + + guard !searchQuery.isEmpty else { + items = .pending + return .cancel(id: Cancellables.fetchingItemsDebounce) + } + + @Dependency(\.moduleClient) + var moduleClient + + items = .loading + + let filters = selectedFilters + + return .run { send in + try await withTaskCancellation(id: Cancellables.fetchingItemsDebounce, cancelInFlight: true) { + try await Task.sleep(nanoseconds: 1_000_000 * 600) + + await send( + .internal( + .loadedItems( + .init { + try await moduleClient.withModule(id: selected) { module in + try await module.search( + .init( + query: searchQuery, + filters: filters.map(\.searchQueryFilter) + ) + ) + } + } + ) + ) + ) + } + } catch: { error, _ in + logger.error("There was an error fetching page \(error.localizedDescription)") + } + } +} + +public extension SearchFeature.State { mutating func clearQuery() -> Effect { query = "" items = .pending @@ -170,6 +233,14 @@ public extension SearchFeature.State { mutating func updateModule(with repoModuleId: RepoModuleID?) -> Effect { self.repoModuleId = repoModuleId - return clearQuery() + self.selectedFilters = .init() + self.allFilters = .init() + return clearQuery().concatenate(with: self.fetchFilters()) + } +} + +private extension SearchFilter { + var searchQueryFilter: SearchQuery.Filter { + .init(id: id, optionId: options.map(\.id)) } } diff --git a/Sources/Features/Search/SearchFeature+View.swift b/Sources/Features/Search/SearchFeature+View.swift index 36577b7..c85682a 100644 --- a/Sources/Features/Search/SearchFeature+View.swift +++ b/Sources/Features/Search/SearchFeature+View.swift @@ -7,6 +7,7 @@ // import Architecture +import ComposableArchitecture import ModuleLists import NukeUI import OrderedCollections @@ -22,153 +23,314 @@ extension SearchFeature.View: View { @MainActor public var body: some View { WithViewStore(store, observe: \.`self`) { viewStore in - SheetDetent( - isExpanded: viewStore.$expandView, - initialHeight: searchBarSize - ) { - ZStack { - LoadableView(loadable: viewStore.items) { pagings in - if pagings.isEmpty { - Text("No results found.") - } else { - ScrollView(.vertical) { - LazyVGrid( - columns: .init( - repeating: .init(alignment: .top), - count: 3 - ), - alignment: .leading - ) { - let allItems = pagings.values.flatMap { $0.value?.items ?? [] } - ForEach(allItems) { item in - VStack(alignment: .leading) { - FillAspectImage(url: item.posterImage) - .aspectRatio(2 / 3, contentMode: .fit) - .cornerRadius(12) - - Text(item.title ?? "Title Unavailable") - .font(.footnote) - } - .contentShape(Rectangle()) - .onTapGesture { - viewStore.send(.didTapPlaylist(item)) - } + VStack(alignment: .leading) { + #if os(macOS) + if viewStore.items.value?.isEmpty ?? true { + filters + } + #endif + LoadableView(loadable: viewStore.items) { pagings in + if pagings.isEmpty { + Text("No results found.") + } else { + ScrollView(.vertical) { + #if os(macOS) + filters + .frame(maxWidth: .infinity, alignment: .leading) + + Text("Items") + .font(.body.weight(.bold)) + .foregroundColor(.gray) + .padding(.horizontal) + .frame(maxWidth: .infinity, alignment: .leading) + #endif + + LazyVGrid( + columns: .init( + repeating: .init(alignment: .top), + count: 3 + ), + alignment: .leading + ) { + let allItems = pagings.values.flatMap { $0.value?.items ?? [] } + ForEach(allItems) { item in + VStack(alignment: .leading) { + FillAspectImage(url: item.posterImage) + .aspectRatio(2 / 3, contentMode: .fit) + .cornerRadius(12) + + Text(item.title ?? "Title Unavailable") + .font(.footnote) + } + .contentShape(Rectangle()) + .onTapGesture { + viewStore.send(.didTapPlaylist(item)) } } - .padding(.horizontal) + } + .padding(.horizontal) - if let lastPage = pagings.values.last { - LoadableView(loadable: lastPage) { page in - LazyView { - Spacer() - .frame(height: 1) - .onAppear { - if let nextPageId = page.nextPage { - store.send(.view(.didShowNextPageIndicator(nextPageId))) - } + if let lastPage = pagings.values.last { + LoadableView(loadable: lastPage) { page in + LazyView { + Spacer() + .frame(height: 1) + .onAppear { + if let nextPageId = page.nextPage { + store.send(.view(.didShowNextPageIndicator(nextPageId))) } - } - } failedView: { _ in - Text("Failed to retrieve content") - .foregroundColor(.red) - } waitingView: { - ProgressView() - .padding(.vertical, 8) + } } + } failedView: { _ in + Text("Failed to retrieve content") + .foregroundColor(.red) + } waitingView: { + ProgressView() + .padding(.vertical, 8) } } } - } failedView: { _ in - Rectangle() - .frame(maxWidth: .infinity) - .frame(height: 100) - .padding() - .overlay(Text("Failed to fetch items")) - } loadingView: { - ProgressView() - } pendingView: { - Text("Type to search") - .font(.body.weight(.semibold)) - .foregroundColor(.gray) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + } failedView: { _ in + Text("Failed to fetch itemss") + } loadingView: { + ProgressView() + } pendingView: { + Text("Type to search") + .font(.body.weight(.semibold)) + .foregroundColor(.gray) } - .safeAreaInset(edge: .top) { + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .bind(viewStore.$searchFieldFocused, to: self.$searchFieldFocused) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + #if os(iOS) + .topBar( + backgroundStyle: .system, + backCallback: { + store.send(.view(.didTapBackButton)) + }, + leadingAccessory: { + WithViewStore(store, observe: \.`self`) { viewStore in VStack(spacing: 10) { - Capsule() - .fill(Color.gray.opacity(0.3)) - .frame(width: 30, height: 6) - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundColor(.gray) - TextField("Search", text: viewStore.$query.removeDuplicates()) - .textFieldStyle(.plain) - .focused($searchFieldFocused) - .frame(maxWidth: .infinity) - - ZStack { - if !viewStore.query.isEmpty { - Image(systemName: "xmark.circle.fill") - .onTapGesture { - store.send(.view(.didTapClearQuery)) - } + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + + TextField("Search...", text: viewStore.$query.removeDuplicates()) + .textFieldStyle(.plain) + .focused($searchFieldFocused) + .frame(maxWidth: .infinity) + .transition(.slide) + .matchedGeometryEffect(id: "Search", in: searchAnimation) + + ZStack { + if !viewStore.query.isEmpty { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.gray) + .onTapGesture { + viewStore.send(.didTapClearQuery) + } + } } + .animation(.easeInOut, value: viewStore.query.isEmpty) } - .animation(.easeInOut, value: viewStore.query.isEmpty) - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background { - RoundedRectangle(cornerRadius: 8) - .style( - withStroke: Color.gray.opacity(0.24), - lineWidth: 1, - fill: Color.gray.opacity(0.14) - ) + .padding(.horizontal) + .padding(.vertical, 12) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .style( + withStroke: Color.gray.opacity(0.16), + lineWidth: 1, + fill: Color.gray.opacity(0.1) + ) + } + .frame(maxHeight: .infinity) } - .padding(.horizontal) - } - .padding(.vertical, 10) - .padding(.bottom, 10) - .background( - RoundedCorners(topRadius: 16) - .style( - withStroke: Color.gray.opacity(0.2), - lineWidth: 1, - fill: .regularMaterial - ) - ) - .readSize { sizeInset in - searchBarSize = sizeInset.size.height - onSearchBarSizeChanged(sizeInset.size) + .fixedSize(horizontal: false, vertical: true) } } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.leading, 8) + }, + trailingAccessory: EmptyView.init, + bottomAccessory: { filters } + ) + #elseif os(macOS) + .navigationTitle("Search") + .toolbar { + ToolbarItem(placement: .navigation) { + Button { + store.send(.view(.didTapBackButton)) + } label: { + Image(systemName: "chevron.left") + } + } + + ToolbarItem(placement: .automatic) { + WithViewStore(store, observe: \.`self`) { viewStore in + TextField("Search...", text: viewStore.$query.removeDuplicates()) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 200) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .bind(viewStore.$searchFieldFocused, to: self.$searchFieldFocused) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.top, 0.1) - .onAppear { + #endif + .task { store.send(.view(.didAppear)) } } } -public extension SearchFeature.View { - func onSearchBarSizeChanged(_ callback: @escaping (CGSize) -> Void) -> Self { - var view = self - view.onSearchBarSizeChanged = callback - return view +extension SearchFeature.View { + private struct FilterView: View { + @Environment(\.theme) + var theme + + @Environment(\.colorScheme) + var scheme + + let filter: SearchFilter + let selectedOptions: [SearchFilter.Option] + let tappedFilterOption: (SearchFilter.Option) -> Void + + var body: some View { + Menu { + ForEach(filter.options) { option in + Button { + tappedFilterOption(option) + } label: { + if selectedOptions[id: option.id] != nil { + Label(option.displayName.capitalized, systemImage: "checkmark") + } else { + Text(option.displayName.capitalized) + } + } + } + } label: { + HStack { + if let option = selectedOptions.first, selectedOptions.count > 1 { + Text("\(filter.displayName.capitalized): \(option.displayName.capitalized) +\(selectedOptions.count - 1)") + } else if let option = selectedOptions.first { + Text("\(filter.displayName.capitalized): \(option.displayName.capitalized)") + } else { + Text(filter.displayName.capitalized) + } + + Image(systemName: "chevron.up.chevron.down") + .font(.footnote.weight(.semibold)) + } + .foregroundColor(selectedOptions.isEmpty ? nil : .white) + .lineLimit(1) + .font(.footnote) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Capsule().style( + withStroke: .gray.opacity(0.2), + fill: selectedOptions.isEmpty ? buttonBackgroundColor : selectedColor + )) + } + .buttonStyle(.plain) + } + + var selectedColor: Color { Theme.pastelGreen } + var buttonBackgroundColor: Color { scheme == .dark ? .init(white: 0.2) : .init(white: 0.94) } } -} -// MARK: - SearchFeature.View.SearchStatus + private struct FiltersState: Equatable { + let isThereFilters: Bool + let selectedFilters: [SearchFilter] + let sortedAllFilters: [SearchFilter] -extension SearchFeature.View { - private enum SearchStatus {} + init(_ state: SearchFeature.State) { + self.isThereFilters = !state.allFilters.isEmpty + self.selectedFilters = state.selectedFilters + var sorted: [SearchFilter] = [] + + for selected in state.selectedFilters { + if let filter = state.allFilters.first(where: \.id == selected.id) { + sorted.append(filter) + } + } + + for filter in state.allFilters where !sorted.contains(where: \.id == filter.id) { + sorted.append(filter) + } + + self.sortedAllFilters = sorted + } + } + + var filters: some View { + WithViewStore(store, observe: FiltersState.init) { viewStore in + VStack(alignment: .leading) { + if viewStore.isThereFilters { + #if os(macOS) + Text("Filters") + .font(.body.weight(.bold)) + .foregroundColor(.gray) + .padding(.horizontal) + #endif + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + if !viewStore.selectedFilters.isEmpty { + Menu { + Section { + Button(role: .destructive) { + viewStore.send(.didTapClearFilters) + } label: { + Text("Clear all filters") + .foregroundColor(.red) + } + } header: { + Text("\(viewStore.selectedFilters.count) filters applied") + } + } label: { + HStack(spacing: 4) { + Image(systemName: "line.3.horizontal.decrease") + Text(viewStore.selectedFilters.count.description) + } + .font(.footnote) + .padding(8) + .foregroundColor(.white) + .background( + Capsule() + .style( + withStroke: .gray.opacity(0.2), + fill: Theme.pastelGreen + ) + ) + } + .buttonStyle(.plain) + .frame(maxHeight: .infinity) + } + + ForEach(viewStore.sortedAllFilters) { filter in + FilterView( + filter: filter, + selectedOptions: viewStore.selectedFilters[id: filter.id]?.options ?? [] + ) { option in + viewStore.send(.didTapFilter(filter, option)) + } + .frame(maxHeight: .infinity) + } + } + .frame(maxHeight: .infinity) + #if os(macOS) + .padding(.horizontal) + #endif + } + .fixedSize(horizontal: false, vertical: true) + } + } + .animation(.easeInOut(duration: 0.2), value: viewStore.selectedFilters.count) + #if os(macOS) + .padding(.top) + #endif + } + } } // MARK: - SearchFeatureView_Previews @@ -178,10 +340,29 @@ extension SearchFeature.View { store: .init( initialState: .init( query: "demo", - filters: .init(), + selectedFilters: .init([ + SearchFilter( + id: .init("1"), + displayName: "Filter", + multiselect: true, + required: false, + options: [.init(id: .init("1"), displayName: "Option 1")] + ) + ]), + allFilters: .init([ + SearchFilter( + id: .init("1"), + displayName: "Filter", + multiselect: true, + required: false, + options: [.init(id: .init("1"), displayName: "Option 1")] + ) + ]), items: .pending ), reducer: { EmptyReducer() } - ) + ), + namespace: Namespace().wrappedValue ) + .themeable() } diff --git a/Sources/Features/Search/SearchFeature.swift b/Sources/Features/Search/SearchFeature.swift index 3be484c..0ffbabc 100644 --- a/Sources/Features/Search/SearchFeature.swift +++ b/Sources/Features/Search/SearchFeature.swift @@ -21,30 +21,30 @@ import ViewComponents public struct SearchFeature: Feature { public struct State: FeatureState { - @BindingState - public var expandView: Bool @BindingState public var searchFieldFocused: Bool @BindingState public var query: String + @BindingState + public var selectedFilters: [SearchFilter] public var repoModuleId: RepoModuleID? - public var filters: [SearchFilter] + public var allFilters: [SearchFilter] public var items: Loadable>>> public init( - expandView: Bool = false, searchFieldFocused: Bool = false, repoModuleId: RepoModuleID? = nil, query: String = "", - filters: [SearchFilter] = [], + selectedFilters: [SearchFilter] = [], + allFilters: [SearchFilter] = [], items: Loadable>>> = .pending ) { self.repoModuleId = repoModuleId - self.expandView = expandView self.searchFieldFocused = searchFieldFocused self.query = query - self.filters = filters + self.selectedFilters = selectedFilters + self.allFilters = allFilters self.items = items } } @@ -55,7 +55,9 @@ public struct SearchFeature: Feature { public enum ViewAction: SendableAction, BindableAction { case didAppear case didTapClearQuery - case didTapFilterOptions + case didTapClearFilters + case didTapBackButton + case didTapFilter(SearchFilter, SearchFilter.Option) case didTapPlaylist(Playlist) case didShowNextPageIndicator(PagingID) case binding(BindingAction) @@ -82,27 +84,26 @@ public struct SearchFeature: Feature { public struct View: FeatureView { public let store: StoreOf - var onSearchBarSizeChanged: (CGSize) -> Void = { _ in } - - @SwiftUI.State - var searchBarSize = 0.0 + public var searchAnimation: Namespace.ID @FocusState var searchFieldFocused: Bool - public nonisolated init(store: StoreOf) { + @MainActor + public init(store: StoreOf, namespace: Namespace.ID) { self.store = store + self.searchAnimation = namespace } } + @Dependency(\.dismiss) + var dismiss + @Dependency(\.moduleClient) var moduleClient @Dependency(\.repoClient) var repoClient - @Dependency(\.logger) - var logger - public init() {} } diff --git a/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift b/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift new file mode 100644 index 0000000..e645f6f --- /dev/null +++ b/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift @@ -0,0 +1,51 @@ +// +// SettingsFeature+iOS.swift +// +// +// Created by ErrorErrorError on 11/27/23. +// +// + +import BuildClient +import Dependencies +import SwiftUI + +#if os(iOS) +extension SettingsFeature.View { + @MainActor + public var listSections: some View { + ScrollView(.vertical) { + VStack(spacing: 16) { + ForEach(SettingsFeature.Section.allCases, id: \.self) { section in + switch section { + case .general: + GeneralView(viewStore: viewStore) + case .appearance: + AppearanceView(viewStore: viewStore) + case .developer: + DeveloperView(viewStore: viewStore) + } + } + + VersionView() + } + } + } +} + +@MainActor +private struct VersionView: View { + @Dependency(\.build) + var build + + var body: some View { + VStack { + Text("Made with ❤️") + Text("Version: \(build.version.description) (\(build.number.rawValue))") + } + .font(.footnote.weight(.medium)) + .foregroundColor(.gray) + .frame(maxWidth: .infinity, alignment: .center) + } +} +#endif diff --git a/Sources/Features/Settings/Platforms/SettingsFeature+macOS.swift b/Sources/Features/Settings/Platforms/SettingsFeature+macOS.swift new file mode 100644 index 0000000..28c483f --- /dev/null +++ b/Sources/Features/Settings/Platforms/SettingsFeature+macOS.swift @@ -0,0 +1,34 @@ +// +// SettingsFeature+macOS.swift +// +// +// Created by ErrorErrorError on 11/27/23. +// +// + +import SwiftUI + +#if os(macOS) +extension SettingsFeature.View { + @MainActor + public var listSections: some View { + TabView { + ForEach(SettingsFeature.Section.allCases, id: \.self) { section in + VStack { + switch section { + case .general: + GeneralView(showTitle: false, viewStore: viewStore) + case .appearance: + AppearanceView(showTitle: false, viewStore: viewStore) + case .developer: + DeveloperView(showTitle: false, viewStore: viewStore) + } + Spacer() + } + .tabItem { Label(section.localized(), systemImage: section.systemImage) } + .tag(section) + } + } + } +} +#endif diff --git a/Sources/Features/Settings/SettingsFeature+Reducer.swift b/Sources/Features/Settings/SettingsFeature+Reducer.swift index 1651abf..1a60d01 100644 --- a/Sources/Features/Settings/SettingsFeature+Reducer.swift +++ b/Sources/Features/Settings/SettingsFeature+Reducer.swift @@ -17,13 +17,7 @@ public extension SettingsFeature { BindingReducer() .onChange(of: \.userSettings) { _, userSettings in Reduce { _, _ in - enum CancelID { case saveDebounce } - return .run { _ in - await userSettingsClient.set(userSettings) - try await withTaskCancellation(id: CancelID.saveDebounce) { - try await Task.sleep(nanoseconds: 500_000_000) - } - } + .run { await userSettingsClient.set(userSettings) } } } } @@ -32,13 +26,18 @@ public extension SettingsFeature { switch action { case let .view(viewAction): switch viewAction { - case .didAppear: + case .onTask: break case .binding: break } + case .internal: + break } return .none } + .forEach(\.path, action: \.internal.path) { + Path() + } } } diff --git a/Sources/Features/Settings/SettingsFeature+View.swift b/Sources/Features/Settings/SettingsFeature+View.swift index 540cc39..449ca8e 100644 --- a/Sources/Features/Settings/SettingsFeature+View.swift +++ b/Sources/Features/Settings/SettingsFeature+View.swift @@ -17,58 +17,109 @@ import ViewComponents extension SettingsFeature.View: View { @MainActor public var body: some View { - WithViewStore(store, observe: \.`self`) { viewStore in - ScrollView(.vertical) { - VStack(spacing: 16) { - SettingsGroup(title: "General") { - // TODO: Actually allow users to set which discover page to show on startup - SettingRow(title: "Discover Page", accessory: { - Toggle("", isOn: .constant(true)) - .labelsHidden() - }) - } + NavStack(store.scope(state: \.path, action: Action.InternalAction.path)) { + listSections + .animation(.easeInOut, value: viewStore.userSettings.developerModeEnabled) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .topBar(title: "Settings") + .task { viewStore.send(.onTask) } + } destination: { store in + SwitchStore(store) { state in } + } + } +} - SettingsGroup(title: "Apearance") { - SettingRow(title: "Theme") { - Text(viewStore.userSettings.theme.name) - .font(.callout.weight(.medium)) - .foregroundColor(theme.textColor.opacity(0.5)) - } content: { - ThemePicker(theme: viewStore.$userSettings.theme) - } +@MainActor +struct GeneralView: View { + var showTitle = true - SettingRow(title: "App Icon", accessory: EmptyView.init) {} - } + @Environment(\.theme) + var theme + + @ObservedObject + var viewStore: FeatureViewStore + + var body: some View { + SettingsGroup(title: showTitle ? SettingsFeature.Section.general.localized() : "") { + // TODO: Actually allow users to set which discover page to show on startup + SettingRow(title: "Discover Page", accessory: { + Toggle("", isOn: .constant(true)) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + }) + } + } +} + +@MainActor +struct AppearanceView: View { + var showTitle = true + + @Environment(\.theme) + var theme + + @ObservedObject + var viewStore: FeatureViewStore + + var body: some View { + SettingsGroup(title: showTitle ? SettingsFeature.Section.appearance.localized() : "") { + SettingRow(title: "Theme") { + Text(viewStore.userSettings.theme.name) + .font(.callout) + .foregroundColor(theme.textColor.opacity(0.65)) + } content: { + ThemePicker(theme: viewStore.$userSettings.theme) + } + + // TODO: Add option to change app icon + SettingRow(title: "App Icon", accessory: EmptyView.init) {} + } + } +} - VStack { - Text("Made with ❤️") - Text("Version: \(viewStore.buildVersion.description) (\(viewStore.buildNumber))") +@MainActor +struct DeveloperView: View { + var showTitle = true + + @Environment(\.theme) + var theme + + @ObservedObject + var viewStore: FeatureViewStore + + var body: some View { + SettingsGroup(title: showTitle ? SettingsFeature.Section.developer.localized() : "") { + SettingRow(title: "Developer Mode", accessory: { + Toggle("", isOn: viewStore.$userSettings.developerModeEnabled) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + }) + + if viewStore.userSettings.developerModeEnabled { + SettingRow(title: "Debug Modules", accessory: { + Button { + // Go to next screen to view logs + } label: { + Image(systemName: "chevron.right") + .font(.footnote) } - .font(.footnote.weight(.medium)) - .foregroundColor(.gray) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical) - } + .buttonStyle(.plain) + }) } } - #if os(iOS) - .topBar(title: "Settings") - #else - .navigationTitle("Settings") - #endif - .frame( - maxWidth: .infinity, - maxHeight: .infinity - ) } } // MARK: - ThemePicker +@MainActor struct ThemePicker: View { @Binding var theme: Theme + @MainActor var body: some View { ScrollView(.horizontal) { HStack(alignment: .center, spacing: 12) { @@ -137,11 +188,15 @@ struct ThemePicker: View { SettingsFeature.View( store: .init( initialState: .init(), - reducer: { - SettingsFeature() - .transformDependency(\.userSettings) { dependency in - dependency.get = { .init(theme: .dark, appIcon: .default) } - } + reducer: { SettingsFeature() }, + withDependencies: { deps in + deps.userSettings.get = { + .init( + theme: .dark, + appIcon: .default, + developerModeEnabled: true + ) + } } ) ) diff --git a/Sources/Features/Settings/SettingsFeature.swift b/Sources/Features/Settings/SettingsFeature.swift index 1c4f0fe..be45c39 100644 --- a/Sources/Features/Settings/SettingsFeature.swift +++ b/Sources/Features/Settings/SettingsFeature.swift @@ -9,27 +9,52 @@ import Architecture import BuildClient import ComposableArchitecture -@preconcurrency import Semver import SharedModels import Styling import SwiftUI import UserSettingsClient public struct SettingsFeature: Feature { + public enum Section: String, Sendable, Hashable, Localizable, CaseIterable { + case general = "General" + case appearance = "Appearance" + case developer = "Developer" + + var systemImage: String { + switch self { + case .general: + "gearshape.fill" + case .appearance: + "paintbrush.fill" + case .developer: + "wrench.and.screwdriver.fill" + } + } + } + + public struct Path: Reducer { + @CasePathable + @dynamicMemberLookup + public enum State: Equatable, Sendable {} + + @CasePathable + public enum Action: Equatable, Sendable {} + + public var body: some ReducerOf { + EmptyReducer() + } + } + public struct State: FeatureState { - public var buildVersion: Semver - public var buildNumber: Int + public var path: StackState @BindingState public var userSettings: UserSettings public init( - buildVersion: Semver = .init(0, 0, 1), - buildNumber: Int = 0 + path: StackState = .init() ) { - self.buildVersion = buildVersion - self.buildNumber = buildNumber - + self.path = path @Dependency(\.userSettings) var userSettings self.userSettings = userSettings.get() @@ -40,7 +65,7 @@ public struct SettingsFeature: Feature { public enum Action: FeatureAction { @CasePathable public enum ViewAction: SendableAction, BindableAction { - case didAppear + case onTask case binding(BindingAction) } @@ -48,7 +73,9 @@ public struct SettingsFeature: Feature { public enum DelegateAction: SendableAction {} @CasePathable - public enum InternalAction: SendableAction {} + public enum InternalAction: SendableAction { + case path(StackAction) + } case view(ViewAction) case delegate(DelegateAction) @@ -58,17 +85,22 @@ public struct SettingsFeature: Feature { @MainActor public struct View: FeatureView { public let store: StoreOf + @ObservedObject + public var viewStore: FeatureViewStore @Environment(\.theme) var theme - public nonisolated init(store: StoreOf) { + @MainActor + public init(store: StoreOf) { self.store = store + self.viewStore = .init(store, observe: \.`self`) } } @Dependency(\.mainQueue) var mainQueue + @Dependency(\.userSettings) var userSettingsClient diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature.swift index 0af015f..db0b72c 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature.swift @@ -186,7 +186,8 @@ public struct VideoPlayerFeature: Feature { @SwiftUI.State var pipStatus = PiPStatus.restoreUI - public nonisolated init(store: StoreOf) { + @MainActor + public init(store: StoreOf) { self.store = store } } @@ -197,9 +198,6 @@ public struct VideoPlayerFeature: Feature { @Dependency(\.moduleClient) var moduleClient - @Dependency(\.logger) - var logger - @Dependency(\.playerClient) var playerClient diff --git a/Sources/Shared/Architecture/TCA+Extensions.swift b/Sources/Shared/Architecture/TCA+Extensions.swift index 647f964..31adeb6 100644 --- a/Sources/Shared/Architecture/TCA+Extensions.swift +++ b/Sources/Shared/Architecture/TCA+Extensions.swift @@ -74,6 +74,8 @@ public struct Case: Reducer where Chi } } +// MARK: - WithViewStore + FeatureAction + public extension WithViewStore where ViewState: Equatable, Content: View { init( _ store: Store, @@ -103,6 +105,10 @@ public extension WithViewStore where ViewState: Equatable, Content: View { } } +// MARK: - ViewStore + FeatureAction + +public typealias FeatureViewStore = ViewStore + public extension ViewStore where ViewState: Equatable { convenience init( _ store: Store, diff --git a/Sources/Shared/SharedModels/Meta.swift b/Sources/Shared/SharedModels/Meta.swift index e183a47..0351918 100644 --- a/Sources/Shared/SharedModels/Meta.swift +++ b/Sources/Shared/SharedModels/Meta.swift @@ -65,12 +65,12 @@ public extension DiscoverListing { // MARK: - SearchFilter -public struct SearchFilter: Identifiable, Equatable, Sendable, Codable { +public struct SearchFilter: Identifiable, Hashable, Sendable, Codable { public let id: Tagged public let displayName: String public let multiselect: Bool public let required: Bool - public let options: [Option] + public var options: [Option] public init( id: ID, @@ -86,7 +86,7 @@ public struct SearchFilter: Identifiable, Equatable, Sendable, Codable { self.options = options } - public struct Option: Identifiable, Equatable, Sendable, Codable { + public struct Option: Identifiable, Hashable, Sendable, Codable { public let id: Tagged public let displayName: String @@ -104,13 +104,13 @@ public struct SearchFilter: Identifiable, Equatable, Sendable, Codable { public struct SearchQuery: Equatable, Sendable, Codable { public var query: String - public var page: PagingID? public var filters: [Filter] + public var page: PagingID? public init( query: String, - page: PagingID? = nil, - filters: [Self.Filter] = [] + filters: [Self.Filter] = [], + page: PagingID? = nil ) { self.query = query self.page = page @@ -119,14 +119,14 @@ public struct SearchQuery: Equatable, Sendable, Codable { public struct Filter: Identifiable, Equatable, Sendable, Codable { public let id: SearchFilter.ID - public let optionIDs: [SearchFilter.Option.ID] + public let optionIds: [SearchFilter.Option.ID] public init( id: ID, optionId: [SearchFilter.Option.ID] = [] ) { self.id = id - self.optionIDs = optionId + self.optionIds = optionId } } } diff --git a/Sources/Shared/SharedModels/RepoModuleID.swift b/Sources/Shared/SharedModels/RepoModuleID.swift index d06cc3a..d5bf306 100644 --- a/Sources/Shared/SharedModels/RepoModuleID.swift +++ b/Sources/Shared/SharedModels/RepoModuleID.swift @@ -16,6 +16,25 @@ public struct RepoModuleID: Hashable, Sendable { public let moduleId: Module.ID } +public extension Repo.ID { + // Follow reverse domain name notation + var displayIdentifier: String { + if rawValue.hasDirectoryPath { + // Assuming this repository is locally stored + "dev.errorerrorerror.mochi.repo.local" + } else { + // Assumes it's a remote url + rawValue.host?.split(separator: ".").reversed().joined(separator: ".") ?? "unknown" + } + } +} + +extension RepoModuleID: CustomStringConvertible { + public var description: String { + "\(repoId.displayIdentifier).\(moduleId)" + } +} + public extension RepoModuleID { static func create(_ repo: Repo, _ module: Module) -> RepoModuleID { .init(repoId: repo.id, moduleId: module.id) diff --git a/Sources/Shared/Styling/NavStack.swift b/Sources/Shared/Styling/NavStack.swift index c445917..df65f6c 100644 --- a/Sources/Shared/Styling/NavStack.swift +++ b/Sources/Shared/Styling/NavStack.swift @@ -35,10 +35,17 @@ public struct NavStack: public var body: some View { if #available(iOS 16, macOS 13, *) { - NavigationStackStore(store, root: root) { store in + NavigationStackStore(store) { + root() + #if os(iOS) + .themeable() + .safeInset(from: \.bottomNavigation, edge: .bottom) + #endif + } destination: { store in #if os(iOS) destination(store) .navigationBarHidden(true) + .safeInset(from: \.bottomNavigation, edge: .bottom) #else destination(store) #endif @@ -48,6 +55,8 @@ public struct NavStack: NavigationView { ZStack { root() + .themeable() + .safeInset(from: \.bottomNavigation, edge: .bottom) Group { WithViewStore(store, observe: \.ids, removeDuplicates: areOrderedSetsDuplicates) { viewStore in @@ -72,6 +81,7 @@ public struct NavStack: ) { store in destination(store) .navigationBarHidden(true) + .safeInset(from: \.bottomNavigation, edge: .bottom) } } label: { EmptyView() @@ -87,36 +97,27 @@ public struct NavStack: #elseif os(macOS) // There is no support for stack-based views under macOS 13, so we create our own stack based // view, and to avoid toolbars from overlapping, we need to only allow one view at a time - ZStack { - WithViewStore(store, observe: \.ids, removeDuplicates: areOrderedSetsDuplicates) { viewStore in - if let id = viewStore.last { - ZStack { - IfLetStore( - store.scope( - state: returningLastNonNilValue { $0[id: id] }, - action: { .element(id: id, action: $0 as Action) } - ), - then: destination - ) - } - } else { - root() - } - } - } - .toolbar { - ToolbarItem(placement: .navigation) { - WithViewStore(store, observe: \.ids, removeDuplicates: areOrderedSetsDuplicates) { viewStore in - Button { - if let last = viewStore.last { - viewStore.send(.popFrom(id: last)) + WithViewStore(store, observe: \.ids, removeDuplicates: areOrderedSetsDuplicates) { viewStore in + if let id = viewStore.last { + IfLetStore( + store.scope( + state: returningLastNonNilValue { $0[id: id] }, + action: { .element(id: id, action: $0 as Action) } + ), + then: destination + ) + .toolbar { + ToolbarItem(placement: .navigation) { + Button { + viewStore.send(.popFrom(id: id)) + } label: { + Image(systemName: "chevron.left") } - } label: { - Image(systemName: "chevron.left") + .keyboardShortcut("[", modifiers: .command) } - .disabled(viewStore.last == nil) - .keyboardShortcut("[", modifiers: .command) } + } else { + root() } } #endif @@ -159,6 +160,7 @@ private func returningLastNonNilValue(_ f: @escaping (A) -> B?) -> (A) -> // MARK: - UINavigationController + UIGestureRecognizerDelegate #if os(iOS) +// FIXME: This causes crashes on iOS 17 /// Hacky way to allow swipe back navigation when status bar is hidden extension UINavigationController: UIGestureRecognizerDelegate { override open func viewDidLoad() { diff --git a/Sources/Shared/Styling/Settings/SettingsGroup.swift b/Sources/Shared/Styling/Settings/SettingsGroup.swift index edcb1a8..3a7d4cf 100644 --- a/Sources/Shared/Styling/Settings/SettingsGroup.swift +++ b/Sources/Shared/Styling/Settings/SettingsGroup.swift @@ -28,14 +28,13 @@ public struct SettingsGroup: View { public var body: some View { VStack(alignment: .leading) { Text(title) - .font(.body.weight(.semibold)) + .font(.caption.weight(.semibold)) .foregroundColor(theme.textColor.opacity(0.85)) .frame(maxWidth: .infinity, alignment: .leading) _VariadicView.Tree(Layout()) { content() } - .clipShape(RoundedRectangle(cornerRadius: 12)) .background { RoundedRectangle(cornerRadius: 12) .style( @@ -44,12 +43,13 @@ public struct SettingsGroup: View { fill: theme.overBackgroundColor ) } + .clipped() } .frame(maxWidth: .infinity) .padding(.horizontal) } - /// From: https://movingparts.io/variadic-views-in-swiftui + /// Source: https://movingparts.io/variadic-views-in-swiftui struct Layout: _VariadicView_UnaryViewRoot { @ViewBuilder func body(children: _VariadicView.Children) -> some View { @@ -62,7 +62,6 @@ public struct SettingsGroup: View { .fill(Color.gray.opacity(0.2)) .frame(maxWidth: .infinity) .frame(height: 1) -// .padding(.horizontal, 12) } } } diff --git a/Sources/Shared/Styling/SheetView.swift b/Sources/Shared/Styling/SheetView.swift index 35de0bc..ce8ac60 100644 --- a/Sources/Shared/Styling/SheetView.swift +++ b/Sources/Shared/Styling/SheetView.swift @@ -113,7 +113,7 @@ public extension View { EmptyView() } -extension Binding { +public extension Binding { func isPresent() -> Binding where Value == Wrapped? { .init( get: { self.wrappedValue != nil }, diff --git a/Sources/Shared/Styling/ThemeModifier.swift b/Sources/Shared/Styling/ThemeModifier.swift index 951a217..e03fde7 100644 --- a/Sources/Shared/Styling/ThemeModifier.swift +++ b/Sources/Shared/Styling/ThemeModifier.swift @@ -22,15 +22,15 @@ private struct ThemeModifier: ViewModifier { func body(content: Content) -> some View { content + .environment(\.theme, currentTheme) + .preferredColorScheme(currentTheme.colorScheme) + .background(currentTheme.backgroundColor.ignoresSafeArea(.all, edges: .all)) + .animation(.easeInOut, value: currentTheme) .task { for await theme in userSettingsClient.theme { currentTheme = theme } } - .environment(\.theme, currentTheme) - .preferredColorScheme(currentTheme.colorScheme) - .background(currentTheme.backgroundColor.ignoresSafeArea(.all, edges: .all)) - .animation(.easeInOut, value: currentTheme) } } diff --git a/Sources/Shared/Styling/TopBar.swift b/Sources/Shared/Styling/TopBar.swift index 9d648bb..f5607b8 100644 --- a/Sources/Shared/Styling/TopBar.swift +++ b/Sources/Shared/Styling/TopBar.swift @@ -69,7 +69,16 @@ public struct TopBarView some View { + self.navigationTitle(title) + } +} +#endif diff --git a/Sources/Shared/ViewComponents/ChipView.swift b/Sources/Shared/ViewComponents/ChipView.swift index a0c3f74..80eb782 100644 --- a/Sources/Shared/ViewComponents/ChipView.swift +++ b/Sources/Shared/ViewComponents/ChipView.swift @@ -36,11 +36,21 @@ public struct ChipView: View { } public extension ChipView { - init(text: String) where Accessory == Text, Background == Material { + init(text: String, material: Material = .ultraThinMaterial) where Accessory == Text, Background == Material { self.init { Text(text) } background: { - .ultraThinMaterial + material + } + } +} + +public extension ChipView { + init(systemName: String, material: Material = .ultraThinMaterial) where Accessory == Image, Background == Material { + self.init { + Image(systemName: systemName) + } background: { + material } } } diff --git a/Sources/Shared/ViewComponents/Extensions/PlatformColor+Ext.swift b/Sources/Shared/ViewComponents/Extensions/PlatformColor+Ext.swift index d294727..fb2c980 100644 --- a/Sources/Shared/ViewComponents/Extensions/PlatformColor+Ext.swift +++ b/Sources/Shared/ViewComponents/Extensions/PlatformColor+Ext.swift @@ -84,11 +84,12 @@ extension PlatformColor { } } +// Source: https://www.jessesquires.com/blog/2023/07/11/creating-dynamic-colors-in-swiftui/ public extension Color { init(light: Color, dark: Color) { #if canImport(UIKit) self.init(light: UIColor(light), dark: UIColor(dark)) - #else + #elseif canImport(AppKit) self.init(light: NSColor(light), dark: NSColor(dark)) #endif } @@ -113,9 +114,7 @@ public extension Color { })) #endif } - #endif - - #if canImport(AppKit) + #elseif canImport(AppKit) init(light: NSColor, dark: NSColor) { self.init(nsColor: NSColor(name: nil, dynamicProvider: { colorScheme in switch colorScheme.name { diff --git a/Sources/Shared/ViewComponents/SnapScroll.swift b/Sources/Shared/ViewComponents/SnapScroll.swift index ac9cee0..87599d2 100644 --- a/Sources/Shared/ViewComponents/SnapScroll.swift +++ b/Sources/Shared/ViewComponents/SnapScroll.swift @@ -11,6 +11,7 @@ import SwiftUI // MARK: - SnapScroll +@MainActor public struct SnapScroll: View where T.Index == Int { @State var position: T.Index @@ -26,6 +27,7 @@ public struct SnapScroll: View where T @GestureState private var translation: CGFloat = 0 + @MainActor public init( alignment: VerticalAlignment = .center, spacing: CGFloat = 0, @@ -41,6 +43,7 @@ public struct SnapScroll: View where T self.content = content } + @MainActor public var body: some View { GeometryReader { proxy in let maxWidth = proxy.size.width @@ -118,7 +121,7 @@ extension SnapScroll.EdgeInsets { #Preview { VStack { SnapScroll( - spacing: 20, + spacing: 0, edgeInsets: .init( leading: 20, trailing: 40 diff --git a/Sources/Shared/ViewComponents/macOS/ToolbarAccessory.swift b/Sources/Shared/ViewComponents/macOS/ToolbarAccessory.swift new file mode 100644 index 0000000..fee680d --- /dev/null +++ b/Sources/Shared/ViewComponents/macOS/ToolbarAccessory.swift @@ -0,0 +1,67 @@ +// +// ToolbarAccessory.swift +// +// +// Created by ErrorErrorError on 11/28/23. +// +// + +#if os(macOS) +import Foundation +import SwiftUI + +public extension View { + func toolbarAccesssory(id: String, _ content: @escaping () -> Content) -> some View { + self.modifier(ToolbarAccessoryViewModifier(id, content)) + } +} + +private struct ToolbarAccessoryViewModifier: ViewModifier { + let id: String + let accessory: () -> Accessory + + init(_ id: String, _ accessory: @escaping () -> Accessory) { + self.id = id + self.accessory = accessory + } + + func body(content: Content) -> some View { + if #available(macOS 13, *) { + content.toolbar { ToolbarItem(placement: .accessoryBar(id: id), content: accessory) } + } else { + content + .onAppear { + guard let window else { + return + } + + if !window.titlebarAccessoryViewControllers.contains(where: { $0.identifier?.rawValue == _internalTitleBarItemId }) { + // No accessory found with this identifier, so create one and add it + let vc = NSTitlebarAccessoryViewController() + vc.view = NSHostingView( + rootView: accessory() + .font(.footnote.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + ) + vc.identifier = .init(rawValue: _internalTitleBarItemId) + window.addTitlebarAccessoryViewController(vc) + } + } + .onDisappear { + guard let window else { + return + } + + if let index = window.titlebarAccessoryViewControllers.firstIndex(where: { $0.identifier?.rawValue == _internalTitleBarItemId }) { + window.removeTitlebarAccessoryViewController(at: index) + } + } + } + } + + private var window: NSWindow? { NSApplication.shared.mainWindow } + private var _internalTitleBarItemId: String { "__internal-\(id)" } +} + +#endif diff --git a/fastlane/Appfile b/fastlane/Appfile index d695df8..a8aba97 100644 --- a/fastlane/Appfile +++ b/fastlane/Appfile @@ -1,4 +1,4 @@ -app_identifier("com.errorerrorerror.mochi") # The bundle identifier of your app +app_identifier("dev.errorerrorerror.mochi") # The bundle identifier of your app # apple_id("[[APPLE_ID]]") # Your Apple Developer Portal username