From 9618bf15d317a47caba30332ffec247c49f44971 Mon Sep 17 00:00:00 2001 From: ErrorErrorError <16653389+ErrorErrorError@users.noreply.github.com> Date: Tue, 12 Dec 2023 23:25:13 -0800 Subject: [PATCH] wip: bump composable-architecture to 1.5.5, use native navigation and toolbar also fixes showing build number and lowercasing repo urls --- App/Mochi.xcodeproj/project.pbxproj | 4 +- .../xcshareddata/xcschemes/Mochi.xcscheme | 9 + App/Shared/MochiApp.swift | 2 +- Package.swift | 3 +- .../Dependencies/ComposableArchitecture.swift | 2 +- Package/Sources/Shared/Architecture.swift | 1 + Sources/Clients/BuildClient/Client.swift | 3 +- .../Clients/LocalizableClient/Client.swift | 4 + .../JS+Bindings/JSContext+JSRuntime.swift | 2 +- Sources/Clients/RepoClient/Live.swift | 6 +- Sources/Clients/RepoClient/Models.swift | 1 + .../Clients/UserSettingsClient/Theme.swift | 6 +- Sources/Features/App/AppFeature.swift | 2 +- .../Features/App/iOS/AppFeatureView+iOS.swift | 132 +++---- .../App/macOS/AppFeatureView+macOS.swift | 6 +- .../Discover/DiscoverFeature+Reducer.swift | 5 +- .../Discover/DiscoverFeature+View.swift | 102 ++--- .../iOS/PlaylistDetailsFeature+View+iOS.swift | 40 +- .../RepoPackagesFeature+View.swift | 28 +- .../Features/Repos/ReposFeature+Reducer.swift | 29 +- .../Features/Repos/ReposFeature+View.swift | 181 ++++----- .../Search/SearchFeature+Reducer.swift | 4 - .../Features/Search/SearchFeature+View.swift | 356 +++++++++--------- Sources/Features/Search/SearchFeature.swift | 14 +- .../Features/Settings/Components/Logs.swift | 61 +-- .../Platforms/SettingsFeature+iOS.swift | 22 +- .../Settings/SettingsFeature+View.swift | 40 +- .../VideoPlayer/VideoPlayerFeature+View.swift | 4 +- Sources/Shared/Architecture/Exported.swift | 9 +- Sources/Shared/Architecture/Feature.swift | 8 + .../Shared/SharedModels/RepoModuleID.swift | 9 +- Sources/Shared/Styling/NavStack.swift | 66 +++- .../Styling/Settings/SettingsGroup.swift | 3 +- .../Shared/Styling/Settings/SettingsRow.swift | 2 +- Sources/Shared/Styling/TopBar.swift | 337 ++--------------- .../ViewComponents/InsetValue+Values.swift | 20 +- .../ViewComponents/ScrollViewTracker.swift | 60 +++ 37 files changed, 667 insertions(+), 916 deletions(-) create mode 100644 Sources/Shared/ViewComponents/ScrollViewTracker.swift diff --git a/App/Mochi.xcodeproj/project.pbxproj b/App/Mochi.xcodeproj/project.pbxproj index 4e06698..a7a9bc6 100644 --- a/App/Mochi.xcodeproj/project.pbxproj +++ b/App/Mochi.xcodeproj/project.pbxproj @@ -371,7 +371,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = Shared/mochi.entitlements; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ..; DEVELOPMENT_TEAM = A6HC4Y86NJ; @@ -411,7 +411,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = Shared/mochi.entitlements; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ..; DEVELOPMENT_TEAM = A6HC4Y86NJ; diff --git a/App/Mochi.xcodeproj/xcshareddata/xcschemes/Mochi.xcscheme b/App/Mochi.xcodeproj/xcshareddata/xcschemes/Mochi.xcscheme index 8ff64c4..80a8315 100644 --- a/App/Mochi.xcodeproj/xcshareddata/xcschemes/Mochi.xcscheme +++ b/App/Mochi.xcodeproj/xcshareddata/xcschemes/Mochi.xcscheme @@ -73,5 +73,14 @@ + + + + + + diff --git a/App/Shared/MochiApp.swift b/App/Shared/MochiApp.swift index bd7b61b..7e2d01d 100644 --- a/App/Shared/MochiApp.swift +++ b/App/Shared/MochiApp.swift @@ -63,7 +63,7 @@ struct MochiApp: App { SettingsFeature.View( store: appDelegate.store.scope( state: \.settings, - action: { .internal(.settings($0)) } + action: \.internal.settings ) ) .themeable() diff --git a/Package.swift b/Package.swift index 24c55d1..857dec9 100644 --- a/Package.swift +++ b/Package.swift @@ -920,7 +920,7 @@ extension _Client { struct ComposableArchitecture: PackageDependency { var dependency: Package.Dependency { - .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.4.2") + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.5.5") } } // @@ -1361,6 +1361,7 @@ struct Architecture: _Shared { FoundationHelpers() ComposableArchitecture() LocalizableClient() + LoggerClient() } } // diff --git a/Package/Sources/Dependencies/ComposableArchitecture.swift b/Package/Sources/Dependencies/ComposableArchitecture.swift index 7c56e2b..472b659 100644 --- a/Package/Sources/Dependencies/ComposableArchitecture.swift +++ b/Package/Sources/Dependencies/ComposableArchitecture.swift @@ -8,6 +8,6 @@ struct ComposableArchitecture: PackageDependency { var dependency: Package.Dependency { - .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.4.2") + .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.5.5") } } diff --git a/Package/Sources/Shared/Architecture.swift b/Package/Sources/Shared/Architecture.swift index e104112..433c0fa 100644 --- a/Package/Sources/Shared/Architecture.swift +++ b/Package/Sources/Shared/Architecture.swift @@ -11,5 +11,6 @@ struct Architecture: _Shared { FoundationHelpers() ComposableArchitecture() LocalizableClient() + LoggerClient() } } diff --git a/Sources/Clients/BuildClient/Client.swift b/Sources/Clients/BuildClient/Client.swift index 9376712..0ae53cd 100644 --- a/Sources/Clients/BuildClient/Client.swift +++ b/Sources/Clients/BuildClient/Client.swift @@ -23,7 +23,8 @@ public struct BuildKey: DependencyKey { .flatMap { $0 as? String } .flatMap { try? Semver($0) } ?? .init(0, 0, 0), number: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") - .flatMap { $0 as? Int } + .flatMap { $0 as? String } + .flatMap { Int($0) } .flatMap { .init(rawValue: $0) } ?? .init(0) ) } diff --git a/Sources/Clients/LocalizableClient/Client.swift b/Sources/Clients/LocalizableClient/Client.swift index ab5fcd9..40e98bf 100644 --- a/Sources/Clients/LocalizableClient/Client.swift +++ b/Sources/Clients/LocalizableClient/Client.swift @@ -17,6 +17,10 @@ extension LocalizableClient: DependencyKey { public static let liveValue: LocalizableClient = .init( localize: { String(localized: .init($0), bundle: .module) } ) + + public static let previewValue: LocalizableClient = .init(localize: { $0 }) + + public static let testValue: LocalizableClient = .init(localize: unimplemented(".localize")) } extension DependencyValues { diff --git a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift index ad6045f..1e4fde8 100644 --- a/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift +++ b/Sources/Clients/ModuleClient/JS+Bindings/JSContext+JSRuntime.swift @@ -25,7 +25,7 @@ extension JSContext { let jsURL = try fileClient.retrieveModuleDirectory(module.mainJSFile) try evaluateScript(String(contentsOf: jsURL)) - evaluateScript("const Instance = new source.default()") + evaluateScript("const Instance = new source.default();") } } diff --git a/Sources/Clients/RepoClient/Live.swift b/Sources/Clients/RepoClient/Live.swift index efdf08b..d23a9b1 100644 --- a/Sources/Clients/RepoClient/Live.swift +++ b/Sources/Clients/RepoClient/Live.swift @@ -12,6 +12,7 @@ import DatabaseClient import Dependencies import FileClient import Foundation +import LoggerClient import Semaphore import SharedModels @@ -145,8 +146,7 @@ private class ModulesDownloadManager { } guard response.mimeType == "text/javascript" || response.mimeType == "application/javascript" else { - print("Unknown mime type of file \(response.mimeType ?? "Unknown")") - throw RepoClient.Error.failedToDownloadModule + throw RepoClient.Error.invalidMimeTypeForModule(received: response.mimeType ?? "Unknown") } guard let directory = URL( @@ -169,7 +169,7 @@ private class ModulesDownloadManager { } } } catch { - print(error) + logger.error("\(error.localizedDescription)") states.value[repoModuleId] = .failed((error as? RepoClient.Error) ?? .failedToDownloadModule) } return nil diff --git a/Sources/Clients/RepoClient/Models.swift b/Sources/Clients/RepoClient/Models.swift index 24ccc52..2978432 100644 --- a/Sources/Clients/RepoClient/Models.swift +++ b/Sources/Clients/RepoClient/Models.swift @@ -15,6 +15,7 @@ public extension RepoClient { enum Error: Swift.Error, Equatable, Sendable { case failedToFindRepo case failedToDownloadModule + case invalidMimeTypeForModule(received: String) case failedToDownloadRepo case failedToAddRepo case failedToInstallModule diff --git a/Sources/Clients/UserSettingsClient/Theme.swift b/Sources/Clients/UserSettingsClient/Theme.swift index e56c63c..4767c43 100644 --- a/Sources/Clients/UserSettingsClient/Theme.swift +++ b/Sources/Clients/UserSettingsClient/Theme.swift @@ -66,9 +66,9 @@ public enum Theme: Codable, Sendable, Hashable, Identifiable, CaseIterable { ) #else .init( - red: 0x0A / 0xFF, - green: 0x0A / 0xFF, - blue: 0x0A / 0xFF + red: 0x10 / 0xFF, + green: 0x10 / 0xFF, + blue: 0x10 / 0xFF ) #endif } diff --git a/Sources/Features/App/AppFeature.swift b/Sources/Features/App/AppFeature.swift index 5cfe123..5d1a172 100644 --- a/Sources/Features/App/AppFeature.swift +++ b/Sources/Features/App/AppFeature.swift @@ -43,7 +43,7 @@ public struct AppFeature: Feature { self.selected = selected } - public enum Tab: String, CaseIterable, Sendable, Localizable { + public enum Tab: String, CaseIterable, Sendable, Localizable, Hashable { case discover = "Discover" case repos = "Repos" case settings = "Settings" diff --git a/Sources/Features/App/iOS/AppFeatureView+iOS.swift b/Sources/Features/App/iOS/AppFeatureView+iOS.swift index 7ffc4bf..e8f378e 100644 --- a/Sources/Features/App/iOS/AppFeatureView+iOS.swift +++ b/Sources/Features/App/iOS/AppFeatureView+iOS.swift @@ -24,45 +24,57 @@ extension AppFeature.View: View { @MainActor public var body: some View { WithViewStore(store, observe: \.selected) { viewStore in - ZStack { - switch viewStore.state { - 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 .settings: - SettingsFeature.View( - store: store.scope( - state: \.settings, - action: Action.InternalAction.settings - ) - ) + TabView( + selection: viewStore.binding( + get: \.`self`, + send: { .didSelectTab($0) } + ) + ) { + ForEach(Self.State.Tab.allCases, id: \.self) { (tab: Self.State.Tab) in + Group { + switch tab { + case .discover: + DiscoverFeature.View( + store: store.scope( + state: \.discover, + action: \.internal.discover + ) + ) + .tint(nil) + case .repos: + ReposFeature.View( + store: store.scope( + state: \.repos, + action: \.internal.repos + ) + ) + .tint(nil) + case .settings: + SettingsFeature.View( + store: store.scope( + state: \.settings, + action: \.internal.settings + ) + ) + .tint(nil) + } + } + .tabItem { + Label(tab.localized, systemImage: viewStore.state == tab ? tab.selected : tab.image) + } + .tag(tab) } } - .inset(for: \.bottomNavigation, alignment: .bottom) { - navbar(viewStore.state) - } - .ignoresSafeArea(.keyboard, edges: .all) - } - .onAppear { - store.send(.view(.didAppear)) + // Set tint of tab item + .tint(viewStore.state.colorAccent) } + .onAppear { store.send(.view(.didAppear)) } .overlay { WithViewStore(store, observe: \.videoPlayer != nil) { isVisible in IfLetStore( store.scope( state: \.$videoPlayer, - action: { .internal(.videoPlayer($0)) } + action: \.internal.videoPlayer ), then: { VideoPlayerFeature.View(store: $0) } ) @@ -73,68 +85,12 @@ extension AppFeature.View: View { } .themeable() } - - @MainActor - func navbar(_ selected: Self.State.Tab) -> some View { - HStack(alignment: .top, spacing: 0) { - ForEach(State.Tab.allCases, id: \.rawValue) { tab in - Button { - store.send(.view(.didSelectTab(tab))) - } label: { - VStack(spacing: 2) { - RoundedRectangle(cornerRadius: 12) - .frame(width: tab == selected ? 18 : 0, height: 4) - .transition(.scale.combined(with: .opacity)) - .opacity(tab == selected ? 1.0 : 0.0) - - Image(systemName: tab == selected ? tab.selected : tab.image) - .resizable() - .aspectRatio(contentMode: .fit) - .font(.system(size: 20, weight: .semibold)) - .frame(height: 18) - .padding(.top, 8) - - Text(tab.localized) - .font(.system(size: 10, weight: .medium)) - } - .foregroundColor(tab == selected ? tab.colorAccent : .gray) - .frame(maxWidth: .infinity) - .background( - Rectangle() - .foregroundColor(tab.colorAccent.opacity(tab == selected ? 0.08 : 0.0)) - .ignoresSafeArea(.all) - .edgesIgnoringSafeArea(.all) - .blur(radius: 24) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.scaled) - .contentShape(Rectangle()) - .animation(.easeInOut(duration: 0.2), value: tab == selected) - } - .frame(maxWidth: .infinity) - } - .frame(maxWidth: .infinity, alignment: .bottom) - .background { - Rectangle() - .fill(.regularMaterial) - .ignoresSafeArea() - .edgesIgnoringSafeArea(.all) - } - .overlay(alignment: .top) { - Color.gray.opacity(0.2) - .frame(height: 0.5) - .frame(maxWidth: .infinity) - .ignoresSafeArea() - .edgesIgnoringSafeArea(.all) - } - } } #Preview { AppFeature.View( store: .init( - initialState: .init(), + initialState: .init(selected: .settings), reducer: { AppFeature() } ) ) diff --git a/Sources/Features/App/macOS/AppFeatureView+macOS.swift b/Sources/Features/App/macOS/AppFeatureView+macOS.swift index 6a6c102..2f7237a 100644 --- a/Sources/Features/App/macOS/AppFeatureView+macOS.swift +++ b/Sources/Features/App/macOS/AppFeatureView+macOS.swift @@ -36,14 +36,14 @@ extension AppFeature.View: View { DiscoverFeature.View( store: store.scope( state: \.discover, - action: Action.InternalAction.discover + action: \.internal.discover ) ) case .repos: ReposFeature.View( store: store.scope( state: \.repos, - action: Action.InternalAction.repos + action: \.internal.repos ) ) case .settings: @@ -65,7 +65,7 @@ extension AppFeature.View: View { .window( store: store.scope( state: \.$videoPlayer, - action: Action.InternalAction.videoPlayer + action: \.internal.videoPlayer ), content: VideoPlayerFeature.View.init ) diff --git a/Sources/Features/Discover/DiscoverFeature+Reducer.swift b/Sources/Features/Discover/DiscoverFeature+Reducer.swift index 9967411..316516f 100644 --- a/Sources/Features/Discover/DiscoverFeature+Reducer.swift +++ b/Sources/Features/Discover/DiscoverFeature+Reducer.swift @@ -40,10 +40,7 @@ extension DiscoverFeature { 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 - ) + state.search = SearchFeature.State(repoModuleId: state.section.module?.module.id) case let .internal(.selectedModule(selection)): if let selection { diff --git a/Sources/Features/Discover/DiscoverFeature+View.swift b/Sources/Features/Discover/DiscoverFeature+View.swift index cd660f9..7ba00a8 100644 --- a/Sources/Features/Discover/DiscoverFeature+View.swift +++ b/Sources/Features/Discover/DiscoverFeature+View.swift @@ -7,7 +7,6 @@ // import Architecture -@_spi(Presentation) import ComposableArchitecture import ModuleLists import Nuke @@ -27,7 +26,7 @@ extension DiscoverFeature.View: View { NavStack( store.scope( state: \.path, - action: Action.InternalAction.screens + action: \.internal.screens ) ) { WithViewStore(store, observe: \.section) { viewStore in @@ -45,55 +44,10 @@ extension DiscoverFeature.View: View { } } .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() - } - } - .transition(.opacity) - } - - 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) - }, - 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("") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif .toolbar { ToolbarItem(placement: .navigation) { Button { @@ -115,17 +69,26 @@ extension DiscoverFeature.View: View { } Text(viewStore.title) - .font(.title3.bold()) Image(systemName: "chevron.down") - .font(.system(size: 1).weight(.semibold)) + .font(.caption.weight(.bold)) + .foregroundColor(.gray) } + #if os(iOS) + .font(.title.bold()) + #else + .font(.title3.bold()) + #endif .contentShape(Rectangle()) .scaleEffect(1.0) .transition(.opacity) .animation(.easeInOut, value: viewStore.icon) } + #if os(macOS) .buttonStyle(.bordered) + #else + .buttonStyle(.plain) + #endif } ToolbarItem(placement: .automatic) { @@ -134,9 +97,11 @@ extension DiscoverFeature.View: View { } label: { Image(systemName: "magnifyingglass") } + #if os(iOS) + .buttonStyle(.materialToolbarItem) + #endif } } - #endif } .ignoresSafeArea(.keyboard) .frame( @@ -144,27 +109,20 @@ extension DiscoverFeature.View: View { maxHeight: .infinity ) .onAppear { store.send(.view(.didAppear)) } + .stackDestination( + store: store.scope( + state: \.$search, + action: \.internal.search + ) + ) { store in + SearchFeature.View(store: store) + } .moduleListsSheet( store.scope( state: \.$moduleLists, - action: { .internal(.moduleLists($0)) } + action: \.internal.moduleLists ) ) - .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(duration: 0.3), value: binding.wrappedValue) - } } destination: { store in SwitchStore(store) { state in switch state { @@ -544,3 +502,9 @@ extension DiscoverFeature.View { ) ) } + +#if os(macOS) +extension ToolbarItemPlacement { + static var topBarTrailing = Self.automatic +} +#endif diff --git a/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift b/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift index 13e3d58..e5b641a 100644 --- a/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift +++ b/Sources/Features/PlaylistDetails/iOS/PlaylistDetailsFeature+View+iOS.swift @@ -81,28 +81,30 @@ extension PlaylistDetailsFeature.View: View { .edgesIgnoringSafeArea(.top) .ignoresSafeArea(.container, edges: .top) #if os(iOS) - .topBar(backgroundStyle: .clear) { - store.send(.view(.didTappedBackButton)) - } trailingAccessory: { - // TODO: Make this change depending if it's in library already or not - Button {} label: { - Image(systemName: "plus") + .navigationBarTitle("", displayMode: .inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button {} label: { + Image(systemName: "plus") + } + .buttonStyle(.materialToolbarItem) } - .buttonStyle(.materialToolbarImage) - Menu { - WithViewStore(store, observe: \.playlist.url) { viewStore in - Button { - openURL(viewStore.state) - } label: { - Image(systemName: "arrow.up.right.square.fill") - Text("Open Playlist URL") + ToolbarItem(placement: .topBarTrailing) { + Menu { + WithViewStore(store, observe: \.playlist.url) { viewStore in + Button { + openURL(viewStore.state) + } label: { + Image(systemName: "arrow.up.right.square.fill") + Text("Open Playlist URL") + } } + } label: { + Image(systemName: "ellipsis") } - } label: { - Image(systemName: "ellipsis") + .menuStyle(.materialToolbarItem) } - .menuStyle(.materialToolbarImage) } #elseif os(macOS) .toolbar { @@ -132,7 +134,7 @@ extension PlaylistDetailsFeature.View: View { .sheet( store: store.scope( state: \.$destination, - action: { .internal(.destination($0)) } + action: \.internal.destination ), state: /PlaylistDetailsFeature.Destination.State.readMore, action: PlaylistDetailsFeature.Destination.Action.readMore @@ -354,7 +356,7 @@ extension PlaylistDetailsFeature.View { ContentCore.View( store: store.scope( state: \.content, - action: Action.InternalAction.content + action: \.internal.content ), contentType: playlistInfo.type ) diff --git a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+View.swift b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+View.swift index a813f66..12e244e 100644 --- a/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+View.swift +++ b/Sources/Features/Repos/RepoPackages/RepoPackagesFeature+View.swift @@ -93,15 +93,27 @@ extension RepoPackagesFeature.View: View { maxHeight: .infinity ) #if os(iOS) - .topBar { - store.send(.view(.didTapClose)) - } trailingAccessory: { - Button { - store.send(.view(.didTapToRefreshRepo)) - } label: { - Image(systemName: "arrow.triangle.2.circlepath") + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) +// .navigationBarBackButtonHidden() + .toolbar { +// ToolbarItem(placement: .topBarLeading) { +// Button { +// store.send(.view(.didTapClose)) +// } label: { +// Image(systemName: "chevron.left") +// } +// .buttonStyle(.materialToolbarItem) +// } + + ToolbarItem(placement: .topBarTrailing) { + Button { + store.send(.view(.didTapToRefreshRepo)) + } label: { + Image(systemName: "arrow.triangle.2.circlepath") + } + .buttonStyle(.materialToolbarItem) } - .buttonStyle(.materialToolbarImage) } #elseif os(macOS) .toolbar { diff --git a/Sources/Features/Repos/ReposFeature+Reducer.swift b/Sources/Features/Repos/ReposFeature+Reducer.swift index 152c57e..c7f47cf 100644 --- a/Sources/Features/Repos/ReposFeature+Reducer.swift +++ b/Sources/Features/Repos/ReposFeature+Reducer.swift @@ -9,6 +9,7 @@ import Architecture import ComposableArchitecture import Foundation +import LoggerClient import RepoClient import SharedModels import Styling @@ -67,7 +68,7 @@ extension ReposFeature { state.path.append(RepoPackagesFeature.State(repo: repo)) case .view(.binding(\.$url)): - guard let url = URL(string: state.url.lowercased()) else { + guard let url = URL(sanitize: state.url) else { state.searchedRepo = .pending return .cancel(id: Cancellables.repoURLDebounce) } @@ -80,7 +81,7 @@ extension ReposFeature { try await send(.internal(.validateRepoURL(.loaded(repoClient.validate(url))))) } } catch: { error, send in - print(error) + logger.error("Failed to validate repo: \(error.localizedDescription)") await send(.internal(.validateRepoURL(.failed(Error.notValidRepo)))) } @@ -109,3 +110,27 @@ extension ReposFeature { } } } + +extension URL { + init?(sanitize string: String) { + var components = URLComponents(string: string) + // Lowercase host and schema since they're not case sensitive + let host = components?.host?.lowercased() + let schema = components?.scheme?.lowercased() + components?.host = host + components?.scheme = schema + + // Everything else is case sensitive, so check if there's a foward slash. If not, add it. + + guard var string = components?.string else { + return nil + } + + // Remove trailing slash + if string.hasSuffix("/") { + string = .init(string.dropLast()) + } + + self.init(string: string) + } +} diff --git a/Sources/Features/Repos/ReposFeature+View.swift b/Sources/Features/Repos/ReposFeature+View.swift index 8f42718..25f0b60 100644 --- a/Sources/Features/Repos/ReposFeature+View.swift +++ b/Sources/Features/Repos/ReposFeature+View.swift @@ -22,74 +22,79 @@ extension ReposFeature.View: View { NavStack( store.scope( state: \.path, - action: { .internal(.path($0)) } + action: \.internal.path ) ) { - WithViewStore(store, observe: \.repos) { viewStore in - ScrollView( - viewStore.isEmpty ? [] : .vertical, - showsIndicators: false - ) { - LazyVStack(spacing: 0) { - repoUrlTextInput - .padding(.horizontal) + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 0) { + repoUrlTextInput + .padding(.horizontal) + WithViewStore(store, observe: \.repos) { viewStore in Spacer() .frame(height: 24) - if !viewStore.isEmpty { - Text("Installed Repos") - .font(.subheadline.weight(.semibold)) - .foregroundColor(.gray) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) + Text("\(viewStore.count) Installed Repos") + .font(.subheadline) + .foregroundColor(.gray) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) - ForEach(viewStore.state) { repo in - repoRow(repo) - .padding(.horizontal) - .background(theme.backgroundColor) - .contentShape(Rectangle()) - .onTapGesture { - self.store.send(.view(.didTapRepo(repo.id))) - } - .contextMenu { - Button { - self.store.send(.view(.didTapDeleteRepo(repo.id))) - } label: { - Label("Delete Repo", systemImage: "trash.fill") - .foregroundColor(.red) + Spacer() + .frame(height: 8) + + ZStack { + if !viewStore.isEmpty { + ForEach(viewStore.state) { repo in + repoRow(repo) + .padding(.horizontal) + .background(theme.backgroundColor) + .contentShape(Rectangle()) + .onTapGesture { + self.store.send(.view(.didTapRepo(repo.id))) + } + .contextMenu { + Button { + self.store.send(.view(.didTapDeleteRepo(repo.id))) + } label: { + Label("Delete Repo", systemImage: "trash.fill") + .foregroundColor(.red) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) + + if viewStore.last?.id != repo.id { + Divider() + .padding(.horizontal) } + } + } else { + VStack(alignment: .leading) { + Text("No Repos Added") + .font(.callout.weight(.medium)) - if viewStore.last?.id != repo.id { - Divider() - .padding(.horizontal) + Text("Add repos to view and install modules.") + .font(.callout) } + .foregroundColor(.gray) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .foregroundColor(.gray.opacity(0.12)) + } + .padding(.horizontal) } } + .animation(.easeInOut, value: viewStore.state) } - .animation(.easeInOut, value: viewStore.state) - } - .overlay { - if viewStore.isEmpty { - noReposView - } - } - } - #if os(iOS) - .topBar(title: "Repos") { - Button { - store.send(.view(.didTapRefreshRepos(nil))) - } label: { - Image(systemName: "arrow.triangle.2.circlepath") } - .buttonStyle(.materialToolbarImage) - } bottomAccessory: { - EmptyView() } - #elseif os(macOS) + .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle("Repos") + #if os(iOS) + .navigationBarTitleDisplayMode(.large) + #endif .toolbar { ToolbarItem(placement: .automatic) { Button { @@ -97,16 +102,12 @@ extension ReposFeature.View: View { } label: { Image(systemName: "arrow.triangle.2.circlepath") } + #if os(iOS) + .buttonStyle(.materialToolbarItem) + #endif } } - #endif - .frame( - maxWidth: .infinity, - maxHeight: .infinity - ) - .task { - store.send(.view(.onTask)) - } + .task { store.send(.view(.onTask)) } } destination: { store in RepoPackagesFeature.View(store: store) } @@ -114,24 +115,6 @@ extension ReposFeature.View: View { } extension ReposFeature.View { - @MainActor - var noReposView: some View { - VStack(spacing: 0) { - Spacer() - .frame(height: 4) - - repoUrlTextInput - .hidden() - - Spacer() - - Text("No repos installed") - .font(.callout) - - Spacer() - } - } - private struct RepoURLInputViewState: Equatable, @unchecked Sendable { @BindingViewState var url: String @@ -162,6 +145,7 @@ extension ReposFeature.View { case .loading: ProgressView() .fixedSize(horizontal: true, vertical: true) + .controlSize(.small) default: Image(systemName: "magnifyingglass") .foregroundColor(.gray) @@ -177,6 +161,7 @@ extension ReposFeature.View { .autocorrectionDisabled(true) #if os(iOS) .textInputAutocapitalization(.never) + .keyboardType(.URL) #endif .font(.system(size: 16, weight: .regular)) .frame(maxWidth: .infinity) @@ -243,18 +228,20 @@ extension ReposFeature.View { func repoRow(_ repo: Repo) -> some View { HStack(alignment: .top, spacing: 16) { LazyImage(url: repo.iconURL) { state in - if let image = state.image { - image - .resizable() - .aspectRatio(contentMode: .fit) - } else { - Image(systemName: "questionmark.square.dashed") - .resizable() - .aspectRatio(contentMode: .fit) - .font(.body.weight(.light)) + Group { + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "questionmark.square.dashed") + .resizable() + .aspectRatio(contentMode: .fit) + .font(.body.weight(.light)) + } } + .frame(width: 38, height: 38) } - .frame(width: 38, height: 38) .squircle() VStack(alignment: .leading, spacing: 2) { @@ -270,24 +257,6 @@ extension ReposFeature.View { } Spacer() - -// WithViewStore(store, observe: \.repoModules[repo.id] ?? .pending) { viewStore in -// ZStack { -// LoadableView(loadable: viewStore.state) { _ in -// Image(systemName: "checkmark.circle.fill") -// .foregroundColor(.green) -// } failedView: { _ in -// Image(systemName: "exclamationmark.triangle.fill") -// .foregroundColor(.red) -// } waitingView: { -// ProgressView() -// .controlSize(.small) -// } -// .frame(width: 34, height: 34) -// .transition(.opacity.combined(with: .scale)) -// } -// .animation(.easeInOut(duration: 0.25), value: viewStore.state) -// } } .frame(maxWidth: .infinity) .padding(.vertical, 14) @@ -300,7 +269,7 @@ extension ReposFeature.View { ReposFeature.View( store: .init( initialState: .init(), - reducer: { ReposFeature() } + reducer: { EmptyReducer() } ) ) } diff --git a/Sources/Features/Search/SearchFeature+Reducer.swift b/Sources/Features/Search/SearchFeature+Reducer.swift index d7626ea..c82e897 100644 --- a/Sources/Features/Search/SearchFeature+Reducer.swift +++ b/Sources/Features/Search/SearchFeature+Reducer.swift @@ -114,16 +114,12 @@ extension SearchFeature: Reducer { 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 diff --git a/Sources/Features/Search/SearchFeature+View.swift b/Sources/Features/Search/SearchFeature+View.swift index c85682a..b224810 100644 --- a/Sources/Features/Search/SearchFeature+View.swift +++ b/Sources/Features/Search/SearchFeature+View.swift @@ -22,72 +22,57 @@ import ViewComponents extension SearchFeature.View: View { @MainActor public var body: some View { - WithViewStore(store, observe: \.`self`) { viewStore in - VStack(alignment: .leading) { - #if os(macOS) - if viewStore.items.value?.isEmpty ?? true { - filters - } - #endif - LoadableView(loadable: viewStore.items) { pagings in + ScrollViewTracker(.vertical) { offset in +// print(offset) + showStatusBarBackground = offset.y < 0 + } content: { + WithViewStore(store, observe: \.items) { viewStore in + LoadableView(loadable: viewStore.state) { 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) + 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)) - } + 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) } } } @@ -100,63 +85,60 @@ extension SearchFeature.View: View { .font(.body.weight(.semibold)) .foregroundColor(.gray) } - .frame(maxWidth: .infinity, maxHeight: .infinity) } - .bind(viewStore.$searchFieldFocused, to: self.$searchFieldFocused) + .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .safeAreaInset(edge: .top) { filters } #if os(iOS) - .topBar( - backgroundStyle: .system, - backCallback: { - store.send(.view(.didTapBackButton)) - }, - leadingAccessory: { + .navigationBarTitle("", displayMode: .inline) + .navigationBarBackButtonHidden() + .toolbar { + ToolbarItem(placement: .navigation) { + SwiftUI.Button { + store.send(.view(.didTapBackButton)) + } label: { + Image(systemName: "chevron.left") + } + .buttonStyle(.materialToolbarItem) + } + + ToolbarItem(placement: .principal) { WithViewStore(store, observe: \.`self`) { viewStore in - VStack(spacing: 10) { - HStack(spacing: 8) { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundColor(.gray) + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.caption) + .foregroundColor(.gray) - TextField("Search...", text: viewStore.$query.removeDuplicates()) - .textFieldStyle(.plain) - .focused($searchFieldFocused) - .frame(maxWidth: .infinity) - .transition(.slide) - .matchedGeometryEffect(id: "Search", in: searchAnimation) + TextField("Search...", text: viewStore.$query.removeDuplicates()) + .textFieldStyle(.plain) + .frame(maxWidth: .infinity) - ZStack { - if !viewStore.query.isEmpty { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.gray) - .onTapGesture { - viewStore.send(.didTapClearQuery) - } - } - } - .animation(.easeInOut, value: viewStore.query.isEmpty) - } - .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) + Button { + viewStore.send(.didTapClearQuery) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.caption) + .foregroundColor(.gray) } - .fixedSize(horizontal: false, vertical: true) + .opacity(viewStore.query.isEmpty ? 0 : 1.0) + .animation(.easeInOut, value: viewStore.query.isEmpty) + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .style( + withStroke: Color.gray.opacity(0.16), + fill: .thickMaterial + ) } + .frame(maxHeight: .infinity) + .padding(.leading, 8) + .fixedSize(horizontal: false, vertical: true) } - .padding(.leading, 8) - }, - trailingAccessory: EmptyView.init, - bottomAccessory: { filters } - ) + } + } #elseif os(macOS) .navigationTitle("Search") .toolbar { @@ -264,71 +246,68 @@ extension SearchFeature.View { 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") + if viewStore.isThereFilters { + 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) } - } 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 - ) - ) + } header: { + Text("\(viewStore.selectedFilters.count) filters applied") + } + } label: { + HStack(spacing: 4) { + Image(systemName: "line.3.horizontal.decrease") + Text(viewStore.selectedFilters.count.description) } - .buttonStyle(.plain) - .frame(maxHeight: .infinity) + .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) + 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) + .frame(maxHeight: .infinity) + .padding(.horizontal) + } + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + .animation(.easeInOut(duration: 0.2), value: viewStore.selectedFilters.count) + .padding(.vertical, 12) + .background { + if showStatusBarBackground { + Rectangle() + .fill(.ultraThinMaterial) + } else { + Rectangle() + .fill(theme.backgroundColor) + } } } - .animation(.easeInOut(duration: 0.2), value: viewStore.selectedFilters.count) - #if os(macOS) - .padding(.top) - #endif } } } @@ -336,33 +315,34 @@ extension SearchFeature.View { // MARK: - SearchFeatureView_Previews #Preview { - SearchFeature.View( - store: .init( - initialState: .init( - query: "demo", - 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 - ) + NavigationView { + SearchFeature.View( + store: .init( + initialState: .init( + query: "demo", + 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() } + ) + ) + } .themeable() } diff --git a/Sources/Features/Search/SearchFeature.swift b/Sources/Features/Search/SearchFeature.swift index 0ffbabc..dc1db27 100644 --- a/Sources/Features/Search/SearchFeature.swift +++ b/Sources/Features/Search/SearchFeature.swift @@ -21,8 +21,6 @@ import ViewComponents public struct SearchFeature: Feature { public struct State: FeatureState { - @BindingState - public var searchFieldFocused: Bool @BindingState public var query: String @BindingState @@ -33,7 +31,6 @@ public struct SearchFeature: Feature { public var items: Loadable>>> public init( - searchFieldFocused: Bool = false, repoModuleId: RepoModuleID? = nil, query: String = "", selectedFilters: [SearchFilter] = [], @@ -41,7 +38,6 @@ public struct SearchFeature: Feature { items: Loadable>>> = .pending ) { self.repoModuleId = repoModuleId - self.searchFieldFocused = searchFieldFocused self.query = query self.selectedFilters = selectedFilters self.allFilters = allFilters @@ -84,15 +80,15 @@ public struct SearchFeature: Feature { public struct View: FeatureView { public let store: StoreOf - public var searchAnimation: Namespace.ID + @SwiftUI.State + var showStatusBarBackground = false - @FocusState - var searchFieldFocused: Bool + @Environment(\.theme) + var theme @MainActor - public init(store: StoreOf, namespace: Namespace.ID) { + public init(store: StoreOf) { self.store = store - self.searchAnimation = namespace } } diff --git a/Sources/Features/Settings/Components/Logs.swift b/Sources/Features/Settings/Components/Logs.swift index 963ec52..e943081 100644 --- a/Sources/Features/Settings/Components/Logs.swift +++ b/Sources/Features/Settings/Components/Logs.swift @@ -202,43 +202,50 @@ public extension Logs { .padding() } #if os(iOS) - .topBar(title: "Logs", backCallback: { - store.send(.didTapBackButton) - }, trailingAccessory: { - Button { - store.send(.didTapViewerList) - } label: { - HStack { - WithViewStore(store, observe: \.selected) { viewStore in - switch viewStore.state { - case .system: - Text("System") - case let .module(_, module, _): - Text(module.name) + .navigationBarTitle("Logs", displayMode: .inline) + .navigationBarBackButtonHidden() + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + store.send(.didTapBackButton) + } label: { + Image(systemName: "chevron.left") + } + .buttonStyle(.materialToolbarItem) + } + + ToolbarItem(placement: .topBarTrailing) { + Button { + store.send(.didTapViewerList) + } label: { + HStack { + WithViewStore(store, observe: \.selected) { viewStore in + switch viewStore.state { + case .system: + Text("System") + case let .module(_, module, _): + Text(module.name) + } } - } - Image(systemName: "chevron.up.chevron.down") + Image(systemName: "chevron.up.chevron.down") + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(.gray.opacity(0.12), in: Capsule()) } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(.gray.opacity(0.12), in: Capsule()) + .buttonStyle(.plain) + .font(.footnote.weight(.medium)) } - .buttonStyle(.plain) - .font(.footnote.weight(.medium)) - .frame(maxHeight: .infinity) - }, bottomAccessory: { - // TODO: Add filters - EmptyView() - }) + } #else - .topBar(title: "Logs") + .navigationTitle("Logs") #endif .task { store.send(.onTask) } .moduleListsSheet( store.scope( state: \.$moduleLists, - action: Logs.Action.moduleLists + action: \.moduleLists ) ) } diff --git a/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift b/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift index e645f6f..e22ac3d 100644 --- a/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift +++ b/Sources/Features/Settings/Platforms/SettingsFeature+iOS.swift @@ -7,6 +7,7 @@ // import BuildClient +import ComposableArchitecture import Dependencies import SwiftUI @@ -39,13 +40,30 @@ private struct VersionView: View { var build var body: some View { - VStack { - Text("Made with ❤️") + VStack(spacing: 12) { + Text(""" + Design and developed by \ + [@errorerrorerror](https://errorerrorerror.dev) \ + & \ + [contributors](https://github.com/Mochi-Team/mochi/contributors) + """ + ) + .multilineTextAlignment(.center) Text("Version: \(build.version.description) (\(build.number.rawValue))") } + .padding(.vertical, 12) + .padding(.horizontal, 12) .font(.footnote.weight(.medium)) .foregroundColor(.gray) .frame(maxWidth: .infinity, alignment: .center) } } + +#Preview { + SettingsFeature.View( + store: .init(initialState: .init()) { + EmptyReducer() + } + ) +} #endif diff --git a/Sources/Features/Settings/SettingsFeature+View.swift b/Sources/Features/Settings/SettingsFeature+View.swift index 628ac1e..2cc24ab 100644 --- a/Sources/Features/Settings/SettingsFeature+View.swift +++ b/Sources/Features/Settings/SettingsFeature+View.swift @@ -17,11 +17,11 @@ import ViewComponents extension SettingsFeature.View: View { @MainActor public var body: some View { - NavStack(store.scope(state: \.path, action: Action.InternalAction.path)) { + NavStack(store.scope(state: \.path, action: \.internal.path)) { listSections .animation(.easeInOut, value: viewStore.userSettings.developerModeEnabled) .frame(maxWidth: .infinity, maxHeight: .infinity) - .topBar(title: "Settings") + .navigationTitle("Settings") .task { viewStore.send(.onTask) } } destination: { store in SwitchStore(store) { state in @@ -49,15 +49,16 @@ struct GeneralView: View { 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) - }) - } + EmptyView() +// 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) +// }) +// } } } @@ -82,7 +83,7 @@ struct AppearanceView: View { } // TODO: Add option to change app icon - SettingRow(title: "App Icon", accessory: EmptyView.init) {} +// SettingRow(title: "App Icon", accessory: EmptyView.init) {} } } } @@ -124,6 +125,9 @@ struct DeveloperView: View { @MainActor struct ThemePicker: View { + @ScaledMetric(relativeTo: .body) + var heightSize = 54 + @Binding var theme: Theme @@ -142,17 +146,15 @@ struct ThemePicker: View { withStroke: self.theme == theme ? Color.accentColor : Color.gray.opacity(0.5), lineWidth: 2, fill: LinearGradient( - colors: [ - Theme.light.backgroundColor, - Theme.light.overBackgroundColor, - Theme.dark.overBackgroundColor, - Theme.dark.backgroundColor + stops: [ + .init(color: Theme.light.backgroundColor, location: 0.5), + .init(color: Theme.dark.backgroundColor, location: 0.5) ], startPoint: .leading, endPoint: .trailing ) ) - .frame(height: 54) + .frame(height: heightSize) .padding(.top, 1) } else { Circle() @@ -165,7 +167,7 @@ struct ThemePicker: View { endPoint: .trailing ) ) - .frame(height: 54) + .frame(height: heightSize) .padding(.top, 1) } diff --git a/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift b/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift index 5bd2df4..3ab1117 100644 --- a/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift +++ b/Sources/Features/VideoPlayer/VideoPlayerFeature+View.swift @@ -363,7 +363,7 @@ extension VideoPlayerFeature.View { ProgressBar( store: store.scope( state: \.player.playback, - action: { $0 } + action: \.self ) ) .foregroundColor(.white) @@ -790,7 +790,6 @@ extension VideoPlayerFeature.View { } } - #Preview { VideoPlayerFeature.View( store: .init( @@ -809,4 +808,3 @@ extension VideoPlayerFeature.View { ) .previewInterfaceOrientation(.landscapeRight) } - diff --git a/Sources/Shared/Architecture/Exported.swift b/Sources/Shared/Architecture/Exported.swift index 135e5d4..c3a78d3 100644 --- a/Sources/Shared/Architecture/Exported.swift +++ b/Sources/Shared/Architecture/Exported.swift @@ -6,9 +6,6 @@ // // -@_exported -import ComposableArchitecture -@_exported -import FoundationHelpers -@_exported -import LocalizableClient +@_exported import ComposableArchitecture +@_exported import FoundationHelpers +@_exported import LocalizableClient diff --git a/Sources/Shared/Architecture/Feature.swift b/Sources/Shared/Architecture/Feature.swift index 5296fe8..b12a2b9 100644 --- a/Sources/Shared/Architecture/Feature.swift +++ b/Sources/Shared/Architecture/Feature.swift @@ -50,8 +50,16 @@ public protocol FeatureAction: Equatable, Sendable { // MARK: - FeatureView +@MainActor public protocol FeatureView: View { associatedtype State: FeatureState associatedtype Action: FeatureAction var store: Store { get } } + +public extension FeatureView { + @discardableResult + func send(_ action: Action.ViewAction) -> StoreTask { + store.send(.view(action)) + } +} diff --git a/Sources/Shared/SharedModels/RepoModuleID.swift b/Sources/Shared/SharedModels/RepoModuleID.swift index d5bf306..04a5035 100644 --- a/Sources/Shared/SharedModels/RepoModuleID.swift +++ b/Sources/Shared/SharedModels/RepoModuleID.swift @@ -19,13 +19,8 @@ public struct RepoModuleID: Hashable, Sendable { 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" - } + // "dev.errorerrorerror.mochi.repo.local" for local storage + rawValue.host?.split(separator: ".").reversed().joined(separator: ".").lowercased() ?? rawValue.absoluteString } } diff --git a/Sources/Shared/Styling/NavStack.swift b/Sources/Shared/Styling/NavStack.swift index d274791..2d8c187 100644 --- a/Sources/Shared/Styling/NavStack.swift +++ b/Sources/Shared/Styling/NavStack.swift @@ -6,6 +6,7 @@ // // +@_spi(Presentation) import ComposableArchitecture import Foundation import OrderedCollections @@ -34,22 +35,17 @@ public struct NavStack: } public var body: some View { - if #available(iOS 16, macOS 13, *) { + if #available(iOS 16.0, macOS 13.0, *) { NavigationStackStore(store) { root() - #if os(iOS) + #if os(iOS) .themeable() - .safeInset(from: \.bottomNavigation, edge: .bottom) - #endif + #endif } destination: { store in - #if os(iOS) destination(store) - .navigationBarHidden(true) + #if os(iOS) .themeable() - .safeInset(from: \.bottomNavigation, edge: .bottom) - #else - destination(store) - #endif + #endif } } else { #if os(iOS) @@ -57,7 +53,6 @@ public struct NavStack: ZStack { root() .themeable() - .safeInset(from: \.bottomNavigation, edge: .bottom) Group { WithViewStore(store, observe: \.ids, removeDuplicates: areOrderedSetsDuplicates) { viewStore in @@ -66,9 +61,7 @@ public struct NavStack: isActive: .init( get: { viewStore.state.contains(id) }, set: { isActive, transaction in - if isActive { - // Stub - } else if !isActive, viewStore.state.contains(id) { + if !isActive, viewStore.state.contains(id) { viewStore.send(.popFrom(id: id), transaction: transaction) } } @@ -81,13 +74,12 @@ public struct NavStack: ) ) { store in destination(store) - .navigationBarHidden(true) .themeable() - .safeInset(from: \.bottomNavigation, edge: .bottom) } } label: { EmptyView() } + .hidden() } } } @@ -158,10 +150,50 @@ private func returningLastNonNilValue(_ f: @escaping (A) -> B?) -> (A) -> } } +public extension View { + @MainActor + func stackDestination( + store: Store, PresentationAction>, + @ViewBuilder destination: @escaping (_ store: Store) -> Destination + ) -> some View { + self.presentation(store: store) { `self`, $item, destinationContent in + if #available(iOS 16.0, macOS 13.0, *) { + self.navigationDestination(isPresented: $item.isPresent()) { + destinationContent(destination) + .themeable() + } + } else if #unavailable(iOS 16.0) { + ZStack { + NavigationLink(isActive: $item.isPresent()) { + destinationContent(destination) + .themeable() + } label: { + EmptyView() + } + .hidden() + + self + } + } else { + // macOS only + ZStack { + if $item.isPresent().wrappedValue { + destinationContent(destination) + .themeable() + } else { + self + } + } + .animation(.interactiveSpring(duration: 0.3), value: $item.isPresent().wrappedValue) + } + } + } +} + // MARK: - UINavigationController + UIGestureRecognizerDelegate #if os(iOS) -// FIXME: This causes crashes on iOS 17 +// 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 3a7d4cf..342ea49 100644 --- a/Sources/Shared/Styling/Settings/SettingsGroup.swift +++ b/Sources/Shared/Styling/Settings/SettingsGroup.swift @@ -36,13 +36,14 @@ public struct SettingsGroup: View { content() } .background { - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: 12, style: .continuous) .style( withStroke: Color.gray.opacity(0.2), lineWidth: 1, fill: theme.overBackgroundColor ) } + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .clipped() } .frame(maxWidth: .infinity) diff --git a/Sources/Shared/Styling/Settings/SettingsRow.swift b/Sources/Shared/Styling/Settings/SettingsRow.swift index 195fe77..23356c5 100644 --- a/Sources/Shared/Styling/Settings/SettingsRow.swift +++ b/Sources/Shared/Styling/Settings/SettingsRow.swift @@ -61,7 +61,7 @@ public struct SettingRow: View { HStack { VStack { Text(title) - .font(.callout.weight(.medium)) + .font(.callout) .foregroundColor(theme.textColor) if let footer { Text(footer) diff --git a/Sources/Shared/Styling/TopBar.swift b/Sources/Shared/Styling/TopBar.swift index 3983e53..bb0f4ed 100644 --- a/Sources/Shared/Styling/TopBar.swift +++ b/Sources/Shared/Styling/TopBar.swift @@ -11,319 +11,26 @@ import SwiftUI import UserSettingsClient import ViewComponents -// MARK: - TopBarBackgroundStyle - -public enum TopBarBackgroundStyle: Equatable { - case system - case gradient(Easing = .easeIn) - case blurred(fade: Bool = false) - case clear -} - -// MARK: - TopBarView - -#if os(iOS) -@MainActor -public struct TopBarView: View { - let backCallback: (() -> Void)? - var backgroundStyle: TopBarBackgroundStyle = .system - let leadingAccessory: () -> LeadingAccessory - let trailingAccessory: () -> TrailingAccessory - let bottomAccessory: () -> BottomAccessory - - @Environment(\.theme) - var theme - - public init( - backgroundStyle: TopBarBackgroundStyle = .system, - backCallback: (() -> Void)? = nil, - @ViewBuilder leadingAccessory: @escaping () -> LeadingAccessory, - @ViewBuilder trailingAccessory: @escaping () -> TrailingAccessory, - @ViewBuilder bottomAccessory: @escaping () -> BottomAccessory - ) { - self.backCallback = backCallback - self.leadingAccessory = leadingAccessory - self.trailingAccessory = trailingAccessory - self.bottomAccessory = bottomAccessory - self.backgroundStyle = backgroundStyle - } - - public var body: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { - if let backCallback { - SwiftUI.Button { - backCallback() - } label: { - Image(systemName: "chevron.backward") - } - .buttonStyle(.materialToolbarImage) - .frame(maxHeight: .infinity) - } - - leadingAccessory() - .frame(maxHeight: .infinity) - - Spacer() - - trailingAccessory() - .frame(maxHeight: .infinity) - } - .fixedSize(horizontal: false, vertical: true) - - bottomAccessory() - } - .safeAreaInset(edge: .leading) { - Spacer() - .frame(width: 0, height: 0) - .padding(.leading) - } - .safeAreaInset(edge: .trailing) { - Spacer() - .frame(width: 0, height: 0) - .padding(.trailing) - } - .padding(.top, 12) - .padding(.bottom, 12) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - ZStack { - switch backgroundStyle { - case .system: - theme.backgroundColor - .transition(.opacity) - case .gradient: - LinearGradient( - gradient: .init( - colors: [ - theme.backgroundColor, - theme.backgroundColor.opacity(0.0) - ] - ), - startPoint: .top, - endPoint: .bottom - ) - .transition(.opacity) - case let .blurred(fade): - Rectangle() - .fill(.ultraThinMaterial) - .preferredColorScheme(theme.colorScheme) - .mask(LinearGradient( - gradient: .init(colors: fade ? [.black, .black.opacity(0)] : [], easing: .easeIn), - startPoint: .top, - endPoint: .bottom - )) - .transition(.opacity) - case .clear: - EmptyView() - .transition(.opacity) - } - } - .edgesIgnoringSafeArea(.top) - .animation(.easeInOut, value: backgroundStyle) - ) - } - - public func backgroundStyle(_ style: TopBarBackgroundStyle) -> Self { - var copy = self - copy.backgroundStyle = style - return copy - } +public extension ButtonStyle where Self == MaterialToolbarItemButtonStyle { + static var materialToolbarItem: MaterialToolbarItemButtonStyle { .init() } } -public extension TopBarView { - init( - backgroundStyle: TopBarBackgroundStyle = .system, - backCallback: (() -> Void)? = nil, - @ViewBuilder leadingAccessory: @escaping () -> LeadingAccessory - ) where TrailingAccessory == EmptyView, BottomAccessory == EmptyView { - self.init( - backgroundStyle: backgroundStyle, - backCallback: backCallback, - leadingAccessory: leadingAccessory, - trailingAccessory: EmptyView.init, - bottomAccessory: EmptyView.init - ) - } - - init( - backgroundStyle: TopBarBackgroundStyle = .system, - backCallback: (() -> Void)? = nil, - @ViewBuilder leadingAccessory: @escaping () -> LeadingAccessory, - @ViewBuilder trailingAccessory: @escaping () -> TrailingAccessory - ) where BottomAccessory == EmptyView { - self.init( - backgroundStyle: backgroundStyle, - backCallback: backCallback, - leadingAccessory: leadingAccessory, - trailingAccessory: trailingAccessory, - bottomAccessory: EmptyView.init - ) - } - - init( - title: String? = nil, - backgroundStyle: TopBarBackgroundStyle = .system, - backCallback: (() -> Void)? = nil, - @ViewBuilder trailingAccessory: @escaping () -> TrailingAccessory, - @ViewBuilder bottomAccessory: @escaping () -> BottomAccessory - ) where LeadingAccessory == Text? { - self.init( - backgroundStyle: backgroundStyle, - backCallback: backCallback, - leadingAccessory: { - if let title { - Text(title) - .font(.title.bold()) - } - }, - trailingAccessory: trailingAccessory, - bottomAccessory: bottomAccessory - ) - } - - init( - title: String? = nil, - backgroundStyle: TopBarBackgroundStyle = .system, - backCallback: (() -> Void)? = nil - ) where LeadingAccessory == Text?, TrailingAccessory == EmptyView, BottomAccessory == EmptyView { - self.init(title: title, backgroundStyle: backgroundStyle, backCallback: backCallback) { - EmptyView() - } bottomAccessory: { - EmptyView() - } - } +// MARK: - MaterialToolbarItemButtonStyle - init( - title: String? = nil, - backgroundStyle: TopBarBackgroundStyle = .system, - backCallback: (() -> Void)? = nil, - @ViewBuilder trailingAccessory: @escaping () -> TrailingAccessory - ) where LeadingAccessory == Text?, BottomAccessory == EmptyView { - self.init(title: title, backgroundStyle: backgroundStyle, backCallback: backCallback) { - trailingAccessory() - } bottomAccessory: { - EmptyView() - } - } - - init( - title: String? = nil, - backgroundStyle: TopBarBackgroundStyle = .system, - backCallback: (() -> Void)? = nil, - @ViewBuilder bottomAccessory: @escaping () -> BottomAccessory - ) where LeadingAccessory == Text?, TrailingAccessory == EmptyView { - self.init(title: title, backgroundStyle: backgroundStyle, backCallback: backCallback) { - EmptyView() - } bottomAccessory: { - bottomAccessory() - } - } -} - -@MainActor -public extension View { - func topBar( - backgroundStyle: TopBarBackgroundStyle = .system, - backCallback: (() -> Void)? = nil, - @ViewBuilder leadingAccessory: @escaping () -> some View, - @ViewBuilder trailingAccessory: @escaping () -> some View, - @ViewBuilder bottomAccessory: @escaping () -> some View - ) -> some View { - safeAreaInset(edge: .top) { - TopBarView( - backgroundStyle: backgroundStyle, - backCallback: backCallback, - leadingAccessory: leadingAccessory, - trailingAccessory: trailingAccessory, - bottomAccessory: bottomAccessory - ) - .frame(maxWidth: .infinity) - } - } - - func topBar( - title: String? = nil, - backgroundStyle: TopBarBackgroundStyle = .system, - backCallback: (() -> Void)? = nil, - @ViewBuilder trailingAccessory: @escaping () -> some View, - @ViewBuilder bottomAccessory: @escaping () -> some View - ) -> some View { - safeAreaInset(edge: .top) { - TopBarView( - title: title, - backgroundStyle: backgroundStyle, - backCallback: backCallback, - trailingAccessory: trailingAccessory, - bottomAccessory: bottomAccessory - ) - .frame(maxWidth: .infinity) - } - } - - func topBar( - title: String? = nil, - backgroundStyle: TopBarBackgroundStyle = .system, - backCallback: (() -> Void)? = nil - ) -> some View { - topBar(title: title, backgroundStyle: backgroundStyle, backCallback: backCallback) { - EmptyView() - } bottomAccessory: { - EmptyView() - } - } - - func topBar( - title: String? = nil, - backgroundStyle: TopBarBackgroundStyle = .system, - backCallback: (() -> Void)? = nil, - @ViewBuilder trailingAccessory: @escaping () -> some View - ) -> some View { - topBar(title: title, backgroundStyle: backgroundStyle, backCallback: backCallback, trailingAccessory: trailingAccessory) { - EmptyView() - } - } - - func topBar( - title: String? = nil, - backgroundStyle: TopBarBackgroundStyle = .system, - backCallback: (() -> Void)? = nil, - @ViewBuilder bottomAccessory: @escaping () -> some View - ) -> some View { - topBar(title: title, backgroundStyle: backgroundStyle, backCallback: backCallback) { - EmptyView() - } bottomAccessory: { - bottomAccessory() - } - } -} - -public extension ButtonStyle where Self == MaterialToolbarImageButtonStyle { - static var materialToolbarImage: MaterialToolbarImageButtonStyle { .init() } -} - -// MARK: - MaterialToolbarImageButtonStyle - -public struct MaterialToolbarImageButtonStyle: ButtonStyle { +public struct MaterialToolbarItemButtonStyle: ButtonStyle { public init() {} - @ScaledMetric - var size = 28.0 - public func makeBody(configuration: Configuration) -> some View { configuration .label - .font(.callout.bold()) - .frame(width: size, height: size) - .background(.ultraThinMaterial, in: Circle()) - .contentShape(Rectangle()) + .materialToolbarItemStyle() .scaleEffect(configuration.isPressed ? 0.85 : 1.0) .animation(.spring(), value: configuration.isPressed) } } public extension MenuStyle where Self == MaterialToolbarButtonMenuStyle { - static var materialToolbarImage: MaterialToolbarButtonMenuStyle { .init() } + static var materialToolbarItem: MaterialToolbarButtonMenuStyle { .init() } } // MARK: - MaterialToolbarButtonMenuStyle @@ -333,20 +40,32 @@ public struct MaterialToolbarButtonMenuStyle: MenuStyle { public func makeBody(configuration: Configuration) -> some View { Menu(configuration) - .foregroundColor(.label) - .font(.callout.bold()) - .frame(width: 28, height: 28) - .background(.ultraThinMaterial, in: Circle()) - .contentShape(Rectangle()) + .materialToolbarItemStyle() } } -#endif +private struct MaterialToolbarItemStyle: ViewModifier { + @ScaledMetric + var fontSize = 12 + + @ScaledMetric + var viewSize = 28 + + func body(content: Content) -> some View { + Circle() + .style(withStroke: .gray.opacity(0.16), fill: .regularMaterial) + .overlay { + content + .foregroundColor(.label) + .font(.system(size: fontSize, weight: .bold, design: .default)) + } + .frame(width: viewSize, height: viewSize, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/) + .contentShape(Rectangle()) + } +} -#if canImport(AppKit) public extension View { - func topBar(title: String) -> some View { - self.navigationTitle(title) + func materialToolbarItemStyle() -> some View { + self.modifier(MaterialToolbarItemStyle()) } } -#endif diff --git a/Sources/Shared/ViewComponents/InsetValue+Values.swift b/Sources/Shared/ViewComponents/InsetValue+Values.swift index af22cc1..2ccbc72 100644 --- a/Sources/Shared/ViewComponents/InsetValue+Values.swift +++ b/Sources/Shared/ViewComponents/InsetValue+Values.swift @@ -10,13 +10,13 @@ import Foundation // MARK: - InsetTabNavigationKey -public struct InsetTabNavigationKey: InsetableKey { - public static var defaultValue: CGSize = .zero -} - -public extension InsetableValues { - var bottomNavigation: CGSize { - get { self[InsetTabNavigationKey.self] } - set { self[InsetTabNavigationKey.self] = newValue } - } -} +// public struct InsetTabNavigationKey: InsetableKey { +// public static var defaultValue: CGSize = .zero +// } +// +// public extension InsetableValues { +// var bottomNavigation: CGSize { +// get { self[InsetTabNavigationKey.self] } +// set { self[InsetTabNavigationKey.self] = newValue } +// } +// } diff --git a/Sources/Shared/ViewComponents/ScrollViewTracker.swift b/Sources/Shared/ViewComponents/ScrollViewTracker.swift new file mode 100644 index 0000000..56365e0 --- /dev/null +++ b/Sources/Shared/ViewComponents/ScrollViewTracker.swift @@ -0,0 +1,60 @@ +// +// ScrollViewTracker.swift +// +// +// Created by ErrorErrorError on 12/12/23. +// +// + +import Foundation +import SwiftUI + +private struct ScrollOffsetPreferenceKey: SwiftUI.PreferenceKey { + static var defaultValue: CGPoint = .zero + + static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { + value = nextValue() + } +} + +private let scrollOffsetNamespace = "scrollView" + +public struct ScrollViewTracker: View { + public init( + _ axis: Axis.Set = [.horizontal, .vertical], + showsIndicators: Bool = true, + onScroll: ScrollAction? = nil, + content: @escaping () -> Content + ) { + self.axis = axis + self.showsIndicators = showsIndicators + self.action = onScroll + self.content = content + } + + private let axis: Axis.Set + private let showsIndicators: Bool + private let content: () -> Content + private let action: ScrollAction? + + public typealias ScrollAction = (_ offset: CGPoint) -> Void + + public var body: some View { + ScrollView(axis, showsIndicators: showsIndicators) { + ZStack(alignment: .top) { + GeometryReader { geo in + Color.clear + .preference( + key: ScrollOffsetPreferenceKey.self, + value: geo.frame(in: .named(scrollOffsetNamespace)).origin + ) + } + .frame(height: 0) + + content() + } + } + .coordinateSpace(name: scrollOffsetNamespace) + .onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: { action?($0) }) + } +}