diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 70036afa87..d6e88b5da0 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -846,7 +846,6 @@ F7E402332BA89551007E5609 /* NCTrash+Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E402322BA89551007E5609 /* NCTrash+Networking.swift */; }; F7E41316294A19B300839300 /* UIView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E41315294A19B300839300 /* UIView+Extension.swift */; }; F7E4D9C422ED929B003675FD /* NCShareCommentsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E4D9C322ED929B003675FD /* NCShareCommentsCell.swift */; }; - F7E562A22EE978B100FA2FDF /* UIWindowScene+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E562A12EE9789B00FA2FDF /* UIWindowScene+Extension.swift */; }; F7E742F32EC0A10C00E2362A /* NCManageDatabase+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF4BF613275629E20081CEEF /* NCManageDatabase+Account.swift */; }; F7E742F42EC0A10C00E2362A /* NCManageDatabase+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF4BF613275629E20081CEEF /* NCManageDatabase+Account.swift */; }; F7E742F52EC0A3DD00E2362A /* NCManageDatabase+Directory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78A10BE29322E8A008499B8 /* NCManageDatabase+Directory.swift */; }; @@ -1756,7 +1755,6 @@ F7E41315294A19B300839300 /* UIView+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+Extension.swift"; sourceTree = ""; }; F7E45E6D21E75BF200579249 /* ja-JP */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ja-JP"; path = "ja-JP.lproj/Localizable.strings"; sourceTree = ""; }; F7E4D9C322ED929B003675FD /* NCShareCommentsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareCommentsCell.swift; sourceTree = ""; }; - F7E562A12EE9789B00FA2FDF /* UIWindowScene+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindowScene+Extension.swift"; sourceTree = ""; }; F7E7AEA42BA32C6500512E52 /* NCCollectionViewDownloadThumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCCollectionViewDownloadThumbnail.swift; sourceTree = ""; }; F7E7AEA62BA32D0000512E52 /* NCCollectionViewUnifiedSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCCollectionViewUnifiedSearch.swift; sourceTree = ""; }; F7E8A390295DC5E0006CB2D0 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; @@ -2803,7 +2801,6 @@ F7B7504A2397D38E004E13EC /* UIImage+Extension.swift */, F7EE66AC2A20B226009AE765 /* UILabel+Extension.swift */, F77BB7492899857B0090FC19 /* UINavigationController+Extension.swift */, - F7E562A12EE9789B00FA2FDF /* UIWindowScene+Extension.swift */, F743C89D2E5B2595000173A9 /* UIScene+Extension.swift */, F77C3F5A2D9BF8B500F3C471 /* UITabBar+Extension.swift */, F77BB747289985270090FC19 /* UITabBarController+Extension.swift */, @@ -4478,7 +4475,6 @@ F7BF9D822934CA21009EE9A6 /* NCManageDatabase+LayoutForView.swift in Sources */, AA8D31662D411FA100FE2775 /* NCShareDateCell.swift in Sources */, F3F442EE2DDE292D00FD701F /* NCMetadataPermissions.swift in Sources */, - F7E562A22EE978B100FA2FDF /* UIWindowScene+Extension.swift in Sources */, F3374A812D64AB9F002A38F9 /* StatusInfo.swift in Sources */, AF7E504E27A2D8FF00B5E4AF /* UIBarButton+Extension.swift in Sources */, AA8D31682D41224800FE2775 /* NCShareToggleCell.swift in Sources */, diff --git a/iOSClient/Extensions/UIWindowScene+Extension.swift b/iOSClient/Extensions/UIWindowScene+Extension.swift deleted file mode 100644 index f351001620..0000000000 --- a/iOSClient/Extensions/UIWindowScene+Extension.swift +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2025 Marino Faggiana -// SPDX-License-Identifier: GPL-3.0-or-later - -import UIKit - -public extension UIWindowScene { - - /// Returns the top-left coordinate of the UITabBar in this scene, - /// expressed in the coordinate space of the scene's key window. - /// - /// If the scene does not host a UITabBarController as root, - /// or no suitable window is found, the method returns `nil`. - var tabBarTopLeft: CGPoint? { - // Select key window if available, otherwise fallback to the first one. - guard let window = windows.first(where: { $0.isKeyWindow }) ?? windows.first, - let tabBarController = window.rootViewController as? UITabBarController - else { - return nil - } - - let tabBar = tabBarController.tabBar - - // Convert tab bar's bounds to the window coordinate space. - let frameInWindow = tabBar.convert(tabBar.bounds, to: window) - - return CGPoint(x: frameInWindow.minX, y: frameInWindow.minY) - } -} diff --git a/iOSClient/GUI/Lucid Banner/UploadBannerView.swift b/iOSClient/GUI/Lucid Banner/UploadBannerView.swift index d41b3d38d0..34659f4acf 100644 --- a/iOSClient/GUI/Lucid Banner/UploadBannerView.swift +++ b/iOSClient/GUI/Lucid Banner/UploadBannerView.swift @@ -26,7 +26,7 @@ struct UploadBannerView: View { let isSuccess = (state.typedStage == .success) let isError = (state.typedStage == .error) - let isButton = (state.typedStage == .init(rawValue: "button")) + let isButton = (state.typedStage == .button) containerView(state: state) { if state.isMinimized { @@ -161,42 +161,69 @@ struct UploadBannerView: View { func containerView(state: LucidBannerState, @ViewBuilder _ content: () -> Content) -> some View { let isError = (state.typedStage == .error) let cornerRadius: CGFloat = 22 + let isMinimized = state.isMinimized - let contentBase = content() + let base = content() .contentShape(Rectangle()) .onTapGesture { guard allowMinimizeOnTap else { return } - UploadBannerCoordinator.shared.handleTap(state) + LucidBannerMinimizeCoordinator.shared.handleTap(state) } - .onDisappear { - UploadBannerCoordinator.shared.clear() - } - // Hard cap for very large screens (iPad etc.) - .frame(maxWidth: 500) - .frame(maxWidth: .infinity, alignment: .center) - if #available(iOS 26, *) { - if isError { - contentBase - .background( - RoundedRectangle(cornerRadius: cornerRadius) - .fill(Color.red.opacity(1)) + if isMinimized { + if #available(iOS 26, *) { + if isError { + base + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color.red.opacity(1)) + ) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius)) + } else { + base + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius)) + } + } else { + let colorBg = isError ? Color.red.opacity(0.9) : Color.white.opacity(0.9) + + base + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(colorBg, lineWidth: 0.6) ) - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius)) + .shadow(color: .black.opacity(0.5), radius: 10, x: 0, y: 4) + } + } else { + let contentBase = base + .frame(maxWidth: 500) + + if #available(iOS 26, *) { + if isError { + contentBase + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color.red.opacity(1)) + ) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius)) + .frame(maxWidth: .infinity, alignment: .center) + } else { + contentBase + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius)) + .frame(maxWidth: .infinity, alignment: .center) + } } else { + let colorBg = isError ? Color.red.opacity(0.9) : Color.white.opacity(0.9) + contentBase - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius)) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(colorBg, lineWidth: 0.6) + ) + .shadow(color: .black.opacity(0.5), radius: 10, x: 0, y: 4) + .frame(maxWidth: .infinity, alignment: .center) } - } else { - let colorBg = isError ? Color.red.opacity(0.9) : Color.white.opacity(0.9) - - contentBase - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .stroke(colorBg, lineWidth: 0.6) - ) - .shadow(color: .black.opacity(0.5), radius: 10, x: 0, y: 4) } } } @@ -258,7 +285,6 @@ func showUploadBanner(scene: UIWindowScene?, stage: LucidBanner.Stage? = nil, policy: LucidBanner.ShowPolicy = .drop, allowMinimizeOnTap: Bool = false, - minimizePoint: CGPoint? = nil, onButtonTap: (() -> Void)? = nil) -> Int? { let token = LucidBanner.shared.show( scene: scene, @@ -275,46 +301,184 @@ func showUploadBanner(scene: UIWindowScene?, onButtonTap: onButtonTap) } - UploadBannerCoordinator.shared.setMinimizePoint(minimizePoint) - UploadBannerCoordinator.shared.register(token: token) +#if !EXTENSION + if allowMinimizeOnTap { + LucidBannerMinimizeCoordinator.shared.register(token: token) { context in + let bounds = context.bounds + let controller = SceneManager.shared.getController(scene: scene) + var height: CGFloat = 55 + let over: CGFloat = 20 + if let scene, + let controller, + let window = scene.windows.first { + let isPadLayout = (window.rootViewController?.traitCollection.horizontalSizeClass == .regular) + if !isPadLayout { + height = controller.barHeightBottom + context.safeAreaInsets.bottom + over + } + } + + return CGPoint( + x: bounds.midX, + y: bounds.maxY - height + ) + } + } +#endif return token } @MainActor -final class UploadBannerCoordinator { - static let shared = UploadBannerCoordinator() +final class LucidBannerMinimizeCoordinator { + static let shared = LucidBannerMinimizeCoordinator() + + // MARK: - Types + + /// Context passed to the mandatory minimize-point resolver. + struct ResolveContext { + /// The active banner token. + let token: Int + + /// The shared banner state instance (SwiftUI observes this). + let state: LucidBannerState + + /// The banner host view (UIKit container for the SwiftUI content). + let hostView: UIView + + /// The window hosting the banner. + let window: UIWindow + + /// Convenience: window bounds. + let bounds: CGRect + + /// Convenience: window safe-area insets. + let safeAreaInsets: UIEdgeInsets + } + + /// Mandatory resolver used to compute the minimized target point. + /// + /// Return a CGPoint in window coordinates where the banner should move + /// when minimized. + typealias ResolveMinimizePointHandler = @MainActor (_ context: ResolveContext) -> CGPoint + + // MARK: - Stored properties private var currentToken: Int? - private var originalCenter: CGPoint? - private var minimizePoint: CGPoint? + private var resolveHandler: ResolveMinimizePointHandler? + private var orientationObserver: NSObjectProtocol? + + // MARK: - Init + + init() { + orientationObserver = NotificationCenter.default.addObserver( + forName: UIDevice.orientationDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + + Task { @MainActor in + // Small delay to let the window/layout settle after rotation. + try? await Task.sleep(for: .milliseconds(250)) + self.refreshPosition(animated: true) + } + } + } + + deinit { + if let orientationObserver { + NotificationCenter.default.removeObserver(orientationObserver) + } + } + + // MARK: - Registration + + /// Registers the active banner token and the mandatory resolver. + /// + /// - Parameters: + /// - token: Banner token returned by `LucidBanner.shared.show(...)`. + /// - resolveMinimizePoint: Mandatory handler that returns the minimized target point. + func register(token: Int?, resolveMinimizePoint: @escaping ResolveMinimizePointHandler) { + guard let token else { + clear() + return + } - func register(token: Int?) { currentToken = token + resolveHandler = resolveMinimizePoint } + /// Clears the coordinator state. func clear() { currentToken = nil - originalCenter = nil - minimizePoint = nil + resolveHandler = nil } - func setMinimizePoint(_ point: CGPoint?) { - minimizePoint = point + // MARK: - Public API + + /// Handles a tap gesture coming from the SwiftUI banner content. + /// + /// The coordinator uses the registered token and resolver. + func handleTap(_ state: LucidBannerState) { + guard let token = currentToken else { return } + + guard LucidBanner.shared.isAlive(token) else { + clear() + return + } + + if state.isMinimized { + maximize(state) + } else { + minimize(state) + } } - @MainActor + /// Refreshes banner position after rotation/layout changes. + /// + /// If minimized, recomputes the target via the resolver and moves there. + /// If not minimized, resets to the standard LucidBanner position. + func refreshPosition(animated: Bool = true) { + guard let token = currentToken else { return } + + guard LucidBanner.shared.isAlive(token) else { + clear() + return + } + + guard let state = LucidBanner.shared.currentState(for: token) else { + return + } + + if state.isMinimized { + guard let target = resolvedMinimizePoint(for: token, state: state) else { + return + } + + LucidBanner.shared.move( + toX: target.x, + y: target.y, + for: token, + animated: animated + ) + } else { + LucidBanner.shared.resetPosition(for: token, animated: true) + } + } + + /// Moves the banner only if it is currently minimized. func moveIfMinimized(to point: CGPoint, animated: Bool = true) { guard let token = currentToken else { return } + guard LucidBanner.shared.isAlive(token) else { clear() return } + guard let state = LucidBanner.shared.currentState(for: token), state.isMinimized else { return } - // Move the minimized banner LucidBanner.shared.move( toX: point.x, y: point.y, @@ -323,33 +487,20 @@ final class UploadBannerCoordinator { ) } - func handleTap(_ state: LucidBannerState) { - guard let token = currentToken else { - return - } - - guard LucidBanner.shared.isAlive(token) else { - clear() - return - } + // MARK: - Private helpers - if state.isMinimized { - maximize(state: state, token: token) - } else { - minimize(state: state, token: token) - } - } + private func minimize(_ state: LucidBannerState) { + guard let token = currentToken else { return } - private func minimize(state: LucidBannerState, token: Int) { - if let frame = LucidBanner.shared.currentFrameInWindow(for: token) { - originalCenter = CGPoint(x: frame.midX, y: frame.midY) - } state.isMinimized = true + // Disable dragging while minimized. LucidBanner.shared.setDraggingEnabled(false, for: token) - LucidBanner.shared.requestRelayout(animated: true) - if let target = minimizePoint { + // Re-measure for the compact (minimized) SwiftUI layout. + LucidBanner.shared.requestRelayout(animated: false) + + if let target = resolvedMinimizePoint(for: token, state: state) { LucidBanner.shared.move( toX: target.x, y: target.y, @@ -359,25 +510,40 @@ final class UploadBannerCoordinator { } } - private func maximize(state: LucidBannerState, token: Int) { + private func maximize(_ state: LucidBannerState) { + guard let token = currentToken else { return } + state.isMinimized = false - LucidBanner.shared.setDraggingEnabled(true, for: token) - LucidBanner.shared.requestRelayout(animated: true) + if state.draggable { + LucidBanner.shared.setDraggingEnabled(true, for: token) + } - // Restore - if let center = originalCenter { - LucidBanner.shared.move( - toX: center.x, - y: center.y, - for: token, - animated: true - ) - } else { - LucidBanner.shared.resetPosition(for: token, animated: true) + // Re-measure for the full SwiftUI layout. + LucidBanner.shared.requestRelayout(animated: false) + + // Let LucidBanner restore the canonical position. + LucidBanner.shared.resetPosition(for: token, animated: true) + } + + private func resolvedMinimizePoint(for token: Int, state: LucidBannerState) -> CGPoint? { + guard let resolveHandler else { return nil } + + guard let hostView = LucidBanner.shared.currentHostView(for: token), + let window = hostView.window else { + return nil } - originalCenter = nil + let ctx = ResolveContext( + token: token, + state: state, + hostView: hostView, + window: window, + bounds: window.bounds, + safeAreaInsets: window.safeAreaInsets + ) + + return resolveHandler(ctx) } } diff --git a/iOSClient/Main/Collection Common/Cell/NCListCell.swift b/iOSClient/Main/Collection Common/Cell/NCListCell.swift index 0f92bde576..8a7f0e3b74 100755 --- a/iOSClient/Main/Collection Common/Cell/NCListCell.swift +++ b/iOSClient/Main/Collection Common/Cell/NCListCell.swift @@ -270,7 +270,7 @@ class NCListCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellProto labelInfo.isHidden = true labelSubinfo.isHidden = true labelInfoSeparator.isHidden = true - + if let tag = tags.first { tag0.text = tag if tags.count > 1 { diff --git a/iOSClient/Main/NCMainTabBarController.swift b/iOSClient/Main/NCMainTabBarController.swift index c6cfaef567..bc8699fe7a 100644 --- a/iOSClient/Main/NCMainTabBarController.swift +++ b/iOSClient/Main/NCMainTabBarController.swift @@ -31,6 +31,14 @@ class NCMainTabBarController: UITabBarController { return SceneManager.shared.getWindow(controller: self) } + var barHeightBottom: CGFloat { + return tabBar.frame.height - tabBar.safeAreaInsets.bottom + } + + var barHeightTop: CGFloat { + return tabBar.frame.height - tabBar.safeAreaInsets.top + } + override func viewDidLoad() { super.viewDidLoad() delegate = self diff --git a/iOSClient/Networking/E2EE/NCNetworkingE2EEUpload.swift b/iOSClient/Networking/E2EE/NCNetworkingE2EEUpload.swift index 94f0119e95..fc10b27b2a 100644 --- a/iOSClient/Networking/E2EE/NCNetworkingE2EEUpload.swift +++ b/iOSClient/Networking/E2EE/NCNetworkingE2EEUpload.swift @@ -259,7 +259,7 @@ class NCNetworkingE2EEUpload: NSObject { systemImage: "gearshape.arrow.triangle.2.circlepath", imageAnimation: .rotate, progress: 0, - stage: .none, + stage: .placeholder, for: tokenBanner) } } diff --git a/iOSClient/Networking/NCNetworkingProcess.swift b/iOSClient/Networking/NCNetworkingProcess.swift index a076e5c02c..fb4f4bb7e8 100644 --- a/iOSClient/Networking/NCNetworkingProcess.swift +++ b/iOSClient/Networking/NCNetworkingProcess.swift @@ -364,10 +364,7 @@ actor NCNetworkingProcess { let scene = await SceneManager.shared.getWindow(sceneIdentifier: metadata.sceneIdentifier)?.windowScene let token = await showUploadBanner(scene: scene, - vPosition: .bottom, - verticalMargin: 55, blocksTouches: true, - minimizePoint: CGPoint(x: 0, y: 0), onButtonTap: { if let currentUploadTask { currentUploadTask.cancel() @@ -379,7 +376,7 @@ actor NCNetworkingProcess { await NCNetworkingE2EEUpload().upload(metadata: metadata, controller: controller, - stageBanner: .init(rawValue: "button"), + stageBanner: .button, tokenBanner: token) { uploadRequest in request = uploadRequest } currentUploadTask: { task in @@ -411,19 +408,13 @@ actor NCNetworkingProcess { var currentUploadTask: Task<(account: String, file: NKFile?, error: NKError), Never>? var tokenBanner: Int? let scene = SceneManager.shared.getWindow(sceneIdentifier: metadata.sceneIdentifier)?.windowScene - let tabBarTopLeft = scene?.tabBarTopLeft ?? CGPoint(x: 0, y: 50) - let minimizePoint = CGPoint( - x: tabBarTopLeft.x + 50, - y: tabBarTopLeft.y - 20 - ) tokenBanner = showUploadBanner(scene: scene, vPosition: .bottom, verticalMargin: 55, draggable: true, - stage: .init(rawValue: "button"), + stage: .button, allowMinimizeOnTap: true, - minimizePoint: minimizePoint, onButtonTap: { if let currentUploadTask { currentUploadTask.cancel() @@ -463,7 +454,8 @@ actor NCNetworkingProcess { title: NSLocalizedString("_finalizing_wait_", comment: ""), systemImage: "gearshape.arrow.triangle.2.circlepath", imageAnimation: .rotate, - stage: .init(rawValue: "none"), + progress: 0, + stage: .placeholder, for: tokenBanner) } }