diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index da11cd9ed5..394a5b7aa7 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -44,6 +44,11 @@ AABD0C8A2D5F67A400F009E6 /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABD0C892D5F67A200F009E6 /* XCUIElement.swift */; }; AABD0C9B2D5F73FC00F009E6 /* Placeholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABD0C9A2D5F73FA00F009E6 /* Placeholder.swift */; }; AAE330042D2ED20200B04903 /* NCShareNavigationTitleSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE330032D2ED1FF00B04903 /* NCShareNavigationTitleSetting.swift */; }; + AAFC0D042F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC0D012F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift */; }; + AAFC0D052F9AA10000F0A001 /* NCFocusedAutoUploadProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC0D022F9AA10000F0A001 /* NCFocusedAutoUploadProgressView.swift */; }; + AAFC0D062F9AA10000F0A001 /* NCFocusedAutoUploadScreenDimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC0D032F9AA10000F0A001 /* NCFocusedAutoUploadScreenDimmer.swift */; }; + AAFC0D092F9AA10000F0A001 /* NCFocusedAutoUploadCloudAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC0D082F9AA10000F0A001 /* NCFocusedAutoUploadCloudAnimation.swift */; }; + AAFC0D0B2F9AA10000F0A001 /* NCAutoUploadCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC0D0A2F9AA10000F0A001 /* NCAutoUploadCounter.swift */; }; AB6000012F60000100FE2775 /* NCTagEditorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6000002F60000100FE2775 /* NCTagEditorModel.swift */; }; AB6000032F60000200FE2775 /* NCTagEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6000022F60000200FE2775 /* NCTagEditorView.swift */; }; AF1A9B6427D0CA1E00F17A9E /* UIAlertController+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1A9B6327D0CA1E00F17A9E /* UIAlertController+Extension.swift */; }; @@ -743,7 +748,7 @@ F7C30E01291BD2610017149B /* NCNetworkingE2EERename.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C30DFF291BD2610017149B /* NCNetworkingE2EERename.swift */; }; F7C55C512FB4A658004A974F /* NCAssistantInputModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C55C502FB4A651004A974F /* NCAssistantInputModel.swift */; }; F7C55C7C2FB5AEF7004A974F /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F7C55C7B2FB5AEF7004A974F /* UniformTypeIdentifiers.framework */; }; - F7C55C882FB5AEF7004A974F /* Action Assistant.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7C55C7A2FB5AEF7004A974F /* Action Assistant.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F7C55C882FB5AEF7004A974F /* Assistant Action.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7C55C7A2FB5AEF7004A974F /* Assistant Action.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F7C55C8D2FB5B02C004A974F /* NCAssistantSharedTextStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FFFC9D2FB300440015441E /* NCAssistantSharedTextStore.swift */; }; F7C55C8E2FB5B03D004A974F /* NCGlobal.swift in Sources */ = {isa = PBXBuildFile; fileRef = F702F2CE25EE5B5C008F8E80 /* NCGlobal.swift */; }; F7C55C8F2FB5B045004A974F /* NCBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76B3CCD1EAE01BD00921AC9 /* NCBrand.swift */; }; @@ -1131,7 +1136,7 @@ 2C33C48623E2C475005F963B /* Notification Service Extension.appex in Embed Foundation Extensions */, F7C9739928F17131002C43E2 /* WidgetDashboardIntentHandler.appex in Embed Foundation Extensions */, F7346E1C28B0EF5E006CE2D2 /* Widget.appex in Embed Foundation Extensions */, - F7C55C882FB5AEF7004A974F /* Action Assistant.appex in Embed Foundation Extensions */, + F7C55C882FB5AEF7004A974F /* Assistant Action.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -1231,6 +1236,11 @@ AACCAB632CFE04F700DA1786 /* lo */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lo; path = lo.lproj/Localizable.strings; sourceTree = ""; }; AACCAB642CFE04F700DA1786 /* lo */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lo; path = lo.lproj/InfoPlist.strings; sourceTree = ""; }; AAE330032D2ED1FF00B04903 /* NCShareNavigationTitleSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareNavigationTitleSetting.swift; sourceTree = ""; }; + AAFC0D012F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCFocusedAutoUploadIntroView.swift; sourceTree = ""; }; + AAFC0D022F9AA10000F0A001 /* NCFocusedAutoUploadProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCFocusedAutoUploadProgressView.swift; sourceTree = ""; }; + AAFC0D032F9AA10000F0A001 /* NCFocusedAutoUploadScreenDimmer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCFocusedAutoUploadScreenDimmer.swift; sourceTree = ""; }; + AAFC0D082F9AA10000F0A001 /* NCFocusedAutoUploadCloudAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCFocusedAutoUploadCloudAnimation.swift; sourceTree = ""; }; + AAFC0D0A2F9AA10000F0A001 /* NCAutoUploadCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAutoUploadCounter.swift; sourceTree = ""; }; AB6000002F60000100FE2775 /* NCTagEditorModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCTagEditorModel.swift; sourceTree = ""; }; AB6000022F60000200FE2775 /* NCTagEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCTagEditorView.swift; sourceTree = ""; }; AF1A9B6327D0CA1E00F17A9E /* UIAlertController+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Extension.swift"; sourceTree = ""; }; @@ -1737,7 +1747,7 @@ F7C30DFC291BD0B80017149B /* NCNetworkingE2EEDelete.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCNetworkingE2EEDelete.swift; sourceTree = ""; }; F7C30DFF291BD2610017149B /* NCNetworkingE2EERename.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCNetworkingE2EERename.swift; sourceTree = ""; }; F7C55C502FB4A651004A974F /* NCAssistantInputModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAssistantInputModel.swift; sourceTree = ""; }; - F7C55C7A2FB5AEF7004A974F /* Action Assistant.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Action Assistant.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + F7C55C7A2FB5AEF7004A974F /* Assistant Action.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Assistant Action.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; F7C55C7B2FB5AEF7004A974F /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; }; F7C55CC32FB5CE74004A974F /* ActionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionViewController.swift; sourceTree = ""; }; F7C55CC42FB5CE74004A974F /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; @@ -2188,6 +2198,15 @@ path = StatusMessage; sourceTree = ""; }; + F3896B062FCF2CF300DA2B18 /* Utility */ = { + isa = PBXGroup; + children = ( + AAFC0D032F9AA10000F0A001 /* NCFocusedAutoUploadScreenDimmer.swift */, + AAFC0D0A2F9AA10000F0A001 /* NCAutoUploadCounter.swift */, + ); + path = Utility; + sourceTree = ""; + }; F389C9F32CEE381E00049762 /* SelectAlbum */ = { isa = PBXGroup; children = ( @@ -2661,9 +2680,13 @@ F76882162C0DD1E7001CF441 /* AutoUpload */ = { isa = PBXGroup; children = ( + F3896B062FCF2CF300DA2B18 /* Utility */, F39A1EE12D0AF8A200DAD522 /* Albums.swift */, F768821B2C0DD1E7001CF441 /* NCAutoUploadView.swift */, F71D2FB62E09BBD700B751CC /* NCAutoUploadModel.swift */, + AAFC0D012F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift */, + AAFC0D022F9AA10000F0A001 /* NCFocusedAutoUploadProgressView.swift */, + AAFC0D082F9AA10000F0A001 /* NCFocusedAutoUploadCloudAnimation.swift */, ); path = AutoUpload; sourceTree = ""; @@ -3313,7 +3336,7 @@ C0046CDA2A17B98400D87C9D /* NextcloudUITests.xctest */, F7F1FBA62E27D13700C79E20 /* Frameworks */, F31165012F9674A1009A1E37 /* AppIcon.icon */, - F7C55C7A2FB5AEF7004A974F /* Action Assistant.appex */, + F7C55C7A2FB5AEF7004A974F /* Assistant Action.appex */, ); sourceTree = ""; }; @@ -3710,9 +3733,9 @@ productReference = F7CE8AFA1DC1F8D8009CAE48 /* Nextcloud.app */; productType = "com.apple.product-type.application"; }; - F7C55C792FB5AEF7004A974F /* Action Assistant */ = { + F7C55C792FB5AEF7004A974F /* Assistant Action */ = { isa = PBXNativeTarget; - buildConfigurationList = F7C55C8C2FB5AEF7004A974F /* Build configuration list for PBXNativeTarget "Action Assistant" */; + buildConfigurationList = F7C55C8C2FB5AEF7004A974F /* Build configuration list for PBXNativeTarget "Assistant Action" */; buildPhases = ( F7C55C762FB5AEF7004A974F /* Sources */, F7C55C772FB5AEF7004A974F /* Frameworks */, @@ -3722,12 +3745,12 @@ ); dependencies = ( ); - name = "Action Assistant"; + name = "Assistant Action"; packageProductDependencies = ( F7C55C9E2FB5B83A004A974F /* NextcloudKit */, ); productName = "Action Assistant"; - productReference = F7C55C7A2FB5AEF7004A974F /* Action Assistant.appex */; + productReference = F7C55C7A2FB5AEF7004A974F /* Assistant Action.appex */; productType = "com.apple.product-type.app-extension"; }; F7C9738F28F17131002C43E2 /* WidgetDashboardIntentHandler */ = { @@ -3915,7 +3938,7 @@ F7346E0F28B0EF5B006CE2D2 /* Widget */, F7C9738F28F17131002C43E2 /* WidgetDashboardIntentHandler */, F71459B41D12E3B700CAFEEC /* Share */, - F7C55C792FB5AEF7004A974F /* Action Assistant */, + F7C55C792FB5AEF7004A974F /* Assistant Action */, F771E3CF20E2392D00AFB62D /* File Provider Extension */, F70716E22987F81400E72C1D /* File Provider Extension UI */, 2C33C47E23E2C475005F963B /* Notification Service Extension */, @@ -4785,6 +4808,11 @@ F76D364628A4F8BF00214537 /* NCActivityIndicator.swift in Sources */, F3A047992BD2668800658E7B /* NCAssistantModel.swift in Sources */, F76882322C0DD1E7001CF441 /* NCAutoUploadView.swift in Sources */, + AAFC0D042F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift in Sources */, + AAFC0D052F9AA10000F0A001 /* NCFocusedAutoUploadProgressView.swift in Sources */, + AAFC0D062F9AA10000F0A001 /* NCFocusedAutoUploadScreenDimmer.swift in Sources */, + AAFC0D092F9AA10000F0A001 /* NCFocusedAutoUploadCloudAnimation.swift in Sources */, + AAFC0D0B2F9AA10000F0A001 /* NCAutoUploadCounter.swift in Sources */, F36E64F72B9245210085ABB5 /* NCCollectionViewCommon+SelectTabBarDelegate.swift in Sources */, F79A65C62191D95E00FF6DCC /* NCSelect.swift in Sources */, F75D19E325EFE09000D74598 /* NCContextMenuTrash.swift in Sources */, @@ -5015,7 +5043,7 @@ }; F7C55C872FB5AEF7004A974F /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = F7C55C792FB5AEF7004A974F /* Action Assistant */; + target = F7C55C792FB5AEF7004A974F /* Assistant Action */; targetProxy = F7C55C862FB5AEF7004A974F /* PBXContainerItemProxy */; }; F7C9739828F17131002C43E2 /* PBXTargetDependency */ = { @@ -6184,7 +6212,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - F7C55C8C2FB5AEF7004A974F /* Build configuration list for PBXNativeTarget "Action Assistant" */ = { + F7C55C8C2FB5AEF7004A974F /* Build configuration list for PBXNativeTarget "Assistant Action" */ = { isa = XCConfigurationList; buildConfigurations = ( F7C55C892FB5AEF7004A974F /* Debug */, diff --git a/iOSClient/Data/NCManageDatabase+AutoUpload.swift b/iOSClient/Data/NCManageDatabase+AutoUpload.swift index 17d3f0fecb..084b4a9fd2 100644 --- a/iOSClient/Data/NCManageDatabase+AutoUpload.swift +++ b/iOSClient/Data/NCManageDatabase+AutoUpload.swift @@ -106,6 +106,28 @@ extension NCManageDatabase { } } + func countAutoUploadMetadatasAsync(account: String, + autoUploadServerUrlBase: String) async -> (pending: Int, failed: Int) { + let global = NCGlobal.shared + let pendingStatuses = global.metadatasStatusInWaitingDownloadUpload + global.metadatasStatusDownloadingUploading + let failedStatuses = [global.metadataStatusUploadError] + + let result = await core.performRealmReadAsync { realm -> (pending: Int, failed: Int) in + let scope = realm.objects(tableMetadata.self) + .filter("account == %@ AND autoUploadServerUrlBase == %@ AND directory == false AND sessionSelector == %@", + account, + autoUploadServerUrlBase, + global.selectorUploadAutoUpload) + + let pendingCount = scope.filter("status IN %@", pendingStatuses).count + let failedCount = scope.filter("status IN %@", failedStatuses).count + + return (pending: pendingCount, failed: failedCount) + } + + return result ?? (pending: 0, failed: 0) + } + func existsAutoUpload(account: String, autoUploadServerUrlBase: String) -> Bool { return core.performRealmRead { realm in diff --git a/iOSClient/DeepLink/NCDeepLinkHandler.swift b/iOSClient/DeepLink/NCDeepLinkHandler.swift index d3df189e87..53178755e3 100644 --- a/iOSClient/DeepLink/NCDeepLinkHandler.swift +++ b/iOSClient/DeepLink/NCDeepLinkHandler.swift @@ -153,6 +153,7 @@ class NCDeepLinkHandler { navigationController.popToRootViewController(animated: false) let autoUploadView = NCAutoUploadView(model: NCAutoUploadModel(controller: controller), albumModel: AlbumModel(controller: controller)) + .environment(NCAutoUploadCounter()) let autoUploadController = UIHostingController(rootView: autoUploadView) navigationController.pushViewController(autoUploadController, animated: true) } diff --git a/iOSClient/More/NCMoreModel.swift b/iOSClient/More/NCMoreModel.swift index 41b8d15f37..1ffb241c28 100644 --- a/iOSClient/More/NCMoreModel.swift +++ b/iOSClient/More/NCMoreModel.swift @@ -37,12 +37,17 @@ final class NCMoreModel: ObservableObject { @Published var quotaProgress: Double = 0 @Published var quotaExternalSiteTitle: String = "" @Published var quotaExternalSiteUrl: String? + @Published var autoUploadStart: Bool = false private weak var controller: NCMainTabBarController? var account: String { controller?.account ?? "" } + var session: NCSession.Session { + NCSession.shared.getSession(controller: controller) + } + private let database = NCManageDatabase.shared private let utilityFileSystem = NCUtilityFileSystem() @@ -166,6 +171,8 @@ final class NCMoreModel: ObservableObject { return } + autoUploadStart = tableAccount.autoUploadStart + var functionItems: [Item] = [] var externalSiteItems: [Item] = [] var settingsItems: [Item] = [] @@ -510,6 +517,23 @@ final class NCMoreModel: ObservableObject { navigationController.pushViewController(settingsController, animated: true) } + /// Opens the SwiftUI auto-upload screen, injecting the shared counter so the row and the + /// screen observe the same source. + func openAutoUpload(counter: NCAutoUploadCounter) { + guard let controller, + let navigationController = controller.currentNavigationController() else { + return + } + + let autoUploadView = NCAutoUploadView(model: NCAutoUploadModel(controller: controller), + albumModel: AlbumModel(controller: controller)) + .environment(counter) + + let hostingController = UIHostingController(rootView: autoUploadView) + + navigationController.pushViewController(hostingController, animated: true) + } + /// Opens an app using a custom URL scheme. /// /// If the app is not installed or the scheme cannot be handled, the fallback URL is opened. diff --git a/iOSClient/More/NCMoreView.swift b/iOSClient/More/NCMoreView.swift index 4e74704f76..8dd56a1a06 100644 --- a/iOSClient/More/NCMoreView.swift +++ b/iOSClient/More/NCMoreView.swift @@ -12,6 +12,7 @@ import NextcloudKit /// inside the UIKit-based `NCMoreNavigationController`. struct NCMoreView: View { @StateObject private var model: NCMoreModel + @State private var autoUploadCounter = NCAutoUploadCounter() private let loadItemsOnAppear: Bool private let shortcutIconColor = Color(red: 0, green: 130 / 255, blue: 201 / 255) // Nextcloud Color @@ -35,42 +36,109 @@ struct NCMoreView: View { } var body: some View { - ZStack { - Color(.systemGroupedBackground) - .ignoresSafeArea() + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 18) { + if let appsSection = model.sections.first(where: { $0.type == .moreApps }) { + moreAppsSection(items: appsSection.items) + } - VStack(spacing: 0) { - content - quotaSection + autoUploadSection + + ForEach(model.sections.filter { $0.type == .regular }) { section in + menuSection(items: section.items) + } + } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 20) } + .overlay(alignment: .bottom) { + // Soften the cut where the scrolling list meets the pinned quota. + LinearGradient(colors: [Color(.systemGroupedBackground).opacity(0), + Color(.systemGroupedBackground)], + startPoint: .top, + endPoint: .bottom) + .frame(height: 32) + .allowsHitTesting(false) + } + + quotaSection } + .background(Color(.systemGroupedBackground).ignoresSafeArea()) .task { guard loadItemsOnAppear else { return } await model.loadItems() } + .onAppear { + updateAutoUploadCounter() + } + .onDisappear { + autoUploadCounter.stop() + } + .onChange(of: model.autoUploadStart) { + updateAutoUploadCounter() + } } - /// Main scrollable content of the More tab. - private var content: some View { - ScrollView { - VStack(spacing: 18) { - ForEach(model.sections) { section in - switch section.type { - case .moreApps: - moreAppsSection(items: section.items) + private func updateAutoUploadCounter() { + let session = model.session - case .regular: - menuSection(items: section.items) + autoUploadCounter.start(account: session.account, + urlBase: session.urlBase, + userId: session.userId, + autoUploadStart: model.autoUploadStart) + } + + /// Auto Upload card: animated cloud icon, a live "items left / failed" subtitle, and a description below. + private var autoUploadSection: some View { + VStack(alignment: .leading, spacing: 6) { + Button { + model.openAutoUpload(counter: autoUploadCounter) + } label: { + HStack(spacing: 16) { + NCFocusedAutoUploadCloudAnimation(size: 44, + cloudColor: Color(NCBrandColor.shared.iconImageColor), + arrowColor: model.autoUploadStart + ? Color(UIColor.systemBackground) + : Color(NCBrandColor.shared.iconImageColor), + isAnimated: model.autoUploadStart) + .frame(width: 39) + + VStack(alignment: .leading, spacing: 2) { + Text(NSLocalizedString("_settings_autoupload_", comment: "")) + .font(.body) + .foregroundColor(Color(NCBrandColor.shared.textColor)) + + if model.autoUploadStart && autoUploadCounter.isLoaded { + Text(autoUploadCounter.itemsLeftSummary) + .font(.footnote) + .foregroundStyle(.secondary) + } } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(Color(.tertiaryLabel)) } + .padding(.horizontal, 16) + .frame(minHeight: 54) + .contentShape(Rectangle()) } - .padding(.horizontal, 20) - .padding(.top, 18) - .padding(.bottom, 20) + .buttonStyle(.plain) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + + Text(NSLocalizedString("_autoupload_description_", comment: "")) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.horizontal, 16) } } - /// Renders the app suggestion shortcut section. + /// Renders the app suggestion shortcut strip; scrolls at the top of the content. /// /// - Parameter items: Shortcut items displayed as cards. private func moreAppsSection(items: [NCMoreModel.Item]) -> some View { @@ -81,7 +149,7 @@ struct NCMoreView: View { } } - /// Creates a tappable shortcut nextcloud card. + /// Creates a tappable shortcut card. /// /// - Parameter item: Item containing title, image and destination. private func shortcutButton(_ item: NCMoreModel.Item) -> some View { @@ -110,9 +178,9 @@ struct NCMoreView: View { .buttonStyle(.plain) } - /// Renders a rounded menu section containing multiple rows. + /// Renders a rounded menu card containing multiple rows. /// - /// - Parameter items: Items displayed in the section. + /// - Parameter items: Items displayed in the card. private func menuSection(items: [NCMoreModel.Item]) -> some View { VStack(spacing: 0) { ForEach(Array(items.enumerated()), id: \.element.identifier) { index, item in @@ -138,7 +206,7 @@ struct NCMoreView: View { Image(systemName: item.image) .font(.icon()) .foregroundColor(Color(NCBrandColor.shared.iconImageColor)) - .frame(width: 26) + .frame(width: 39) Text(NSLocalizedString(item.titleKey, comment: "")) .font(.body) @@ -164,43 +232,45 @@ struct NCMoreView: View { Rectangle() .fill(Color(.separator).opacity(0.45)) .frame(height: 0.5) - .padding(.leading, 58) + .padding(.leading, 71) } + @ViewBuilder private var quotaSection: some View { - VStack(alignment: .leading, spacing: 8) { - if !model.quotaDescription.isEmpty { - Text(model.quotaDescription) - .font(.footnote) - .foregroundColor(.primary) - .lineLimit(2) - .tint(.primary) + if !model.quotaDescription.isEmpty || !model.quotaExternalSiteTitle.isEmpty { + VStack(alignment: .leading, spacing: 8) { + if !model.quotaDescription.isEmpty { + Text(model.quotaDescription) + .font(.footnote) + .foregroundColor(.primary) + .lineLimit(2) - quotaProgressView - } + quotaProgressView + } - if !model.quotaExternalSiteTitle.isEmpty, - let url = model.quotaExternalSiteUrl { - Button { - model.perform( - .browser( - url: url, - title: model.quotaExternalSiteTitle + if !model.quotaExternalSiteTitle.isEmpty, + let url = model.quotaExternalSiteUrl { + Button { + model.perform( + .browser( + url: url, + title: model.quotaExternalSiteTitle + ) ) - ) - } label: { - Text(model.quotaExternalSiteTitle) - .font(.footnote) - .lineLimit(1) - .tint(.primary) + } label: { + Text(model.quotaExternalSiteTitle) + .font(.footnote) + .lineLimit(1) + .tint(.primary) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 16) } - .padding(.horizontal, 20) - .padding(.top, 8) - .padding(.bottom, 16) - .background(Color(.systemGroupedBackground)) } private var normalizedQuotaProgress: Double { diff --git a/iOSClient/NCGlobal.swift b/iOSClient/NCGlobal.swift index 2e6ad38aee..8ca394dfc4 100644 --- a/iOSClient/NCGlobal.swift +++ b/iOSClient/NCGlobal.swift @@ -291,6 +291,7 @@ final class NCGlobal: Sendable { let notificationCenterUserInteractionMonitor = "serInteractionMonitor" let notificationCenterNetworkingProcess = "networkingProcess" + let notificationCenterTransferCountChanged = "transferCountChanged" // Networking Status let networkingStatusCreateFolder = "statusCreateFolder" diff --git a/iOSClient/Networking/NCNetworkingProcess.swift b/iOSClient/Networking/NCNetworkingProcess.swift index 930a965320..5f8e8d20bd 100644 --- a/iOSClient/Networking/NCNetworkingProcess.swift +++ b/iOSClient/Networking/NCNetworkingProcess.swift @@ -28,7 +28,7 @@ actor NCNetworkingProcess { private var enableControllingScreenAwake = true private var currentAccount = "" - private var inWaitDownloadUploadCount: Int = 0 + private var lastScheduledAndInProgressCount: Int = 0 private var timer: DispatchSourceTimer? private let timerQueue = DispatchQueue(label: "com.nextcloud.timerProcess", qos: .utility) @@ -70,7 +70,7 @@ actor NCNetworkingProcess { guard let self else { return } Task { - let count = await self.inWaitingDownloadUploadCount() + let count = await self.scheduledAndInProgressCount() try? await UNUserNotificationCenter.current().setBadgeCount(count) await self.stopTimer() @@ -126,12 +126,10 @@ actor NCNetworkingProcess { currentAccount = account } - private func inWaitingDownloadUploadCount() async -> Int { - let countTransferDownloadingUploadingSuccess = await NCNetworking.shared.metadataTranfersSuccess.count(statuses: NCGlobal.shared.metadatasStatusDownloadingUploading) - let countWaitingDownloadUpload = await NCManageDatabase.shared.getMetadatasStatusCountAsync(status: NCGlobal.shared.metadatasStatusInWaitingDownloadUpload) - let count = max(0, countWaitingDownloadUpload - countTransferDownloadingUploadingSuccess) + private func scheduledAndInProgressCount() async -> Int { + let statuses = NCGlobal.shared.metadatasStatusInWaitingDownloadUpload + NCGlobal.shared.metadatasStatusDownloadingUploading - return count + return await NCManageDatabase.shared.getMetadatasStatusCountAsync(status: statuses) } func startTimer(interval: TimeInterval) async { @@ -198,17 +196,19 @@ actor NCNetworkingProcess { return } - // UPDATE INWAIT DOWNLOAD UPLOAD & BADGE + // UPDATE SCHEDULED + IN PROGRESS & BADGE // - let count = await inWaitingDownloadUploadCount() - if count != inWaitDownloadUploadCount { - inWaitDownloadUploadCount = count + let count = await scheduledAndInProgressCount() + if count != lastScheduledAndInProgressCount { + lastScheduledAndInProgressCount = count Task { @MainActor in if let controller = getRootController(), let files = controller.tabBar.items?.first { files.badgeValue = count == 0 ? nil : self.utility.formatBadgeCount(count) } } + + NotificationCenter.default.post(name: NSNotification.Name(rawValue: global.notificationCenterTransferCountChanged), object: nil) } // METADATAS diff --git a/iOSClient/Settings/AutoUpload/NCAutoUploadModel.swift b/iOSClient/Settings/AutoUpload/NCAutoUploadModel.swift index 0bdabce6cb..9845f8962e 100644 --- a/iOSClient/Settings/AutoUpload/NCAutoUploadModel.swift +++ b/iOSClient/Settings/AutoUpload/NCAutoUploadModel.swift @@ -17,46 +17,50 @@ enum AutoUploadTimespan: String, CaseIterable, Identifiable { /// A model that allows the user to configure the `auto upload settings for Nextcloud` class NCAutoUploadModel: ObservableObject, ViewOnAppearHandling { - // A state variable that indicates whether auto upload for photos is enabled or not + /// Whether auto upload for photos is enabled or not @Published var autoUploadImage: Bool = false - // A state variable that indicates whether auto upload for photos is restricted to Wi-Fi only or not + /// Whether auto upload for photos is restricted to Wi-Fi only or not @Published var autoUploadWWAnPhoto: Bool = false - // A state variable that indicates whether auto upload for videos is enabled or not + /// Whether auto upload for videos is enabled or not @Published var autoUploadVideo: Bool = false - // A state variable that indicates whether auto upload for videos is enabled or not + /// Whether auto upload for videos is enabled or not @Published var autoUploadWWAnVideo: Bool = false - // A state variable that indicates whether auto upload is enabled or not + /// Whether auto upload is enabled or not @Published var autoUploadStart: Bool = false - // A state variable that indicates whether auto upload creates subfolders based on date or not + /// Whether auto upload creates subfolders based on date or not @Published var autoUploadCreateSubfolder: Bool = false - // A state variable that indicates the granularity of the subfolders, either daily, monthly, or yearly + /// The granularity of the subfolders, either daily, monthly, or yearly @Published var autoUploadSubfolderGranularity: Granularity = .monthly - // A state variable that indicates the date from when new photos/videos will be uploaded. + /// The date from when new photos/videos will be uploaded. @Published var autoUploadSinceDate: Date? - // A state variable that indicates whether a warning should be shown if all photos must be uploaded. + /// Whether a warning should be shown if all photos must be uploaded. @Published var showUploadAllPhotosWarning = false - // A state variable that indicates whether Photos permissions have been granted or not. + /// Whether Photos permissions have been granted or not. @Published var photosPermissionsGranted = true - // - @Published var permissionGranted: Bool = false + /// Whether `Always` location authorization has been granted, enabling background location-based auto upload. + @Published var locationAutoUploadPermissionGranted: Bool = false - // A state variable that shows error in view in case of an error + /// Whether the error alert should be shown in the view. @Published var showErrorAlert: Bool = false + /// The currently displayed section name. @Published var sectionName = "" + /// Whether the user is authorized. @Published var isAuthorized: Bool = false - // A string variable that contains error text + /// Error text shown to the user. @Published var error: String = "" + /// Shared Nextcloud database instance. let database = NCManageDatabase.shared - // Root View Controller + /// Root view controller used to present UI from this model. var controller: NCMainTabBarController? - // A variable user for change the auto upload directory + /// Server URL used to change the auto-upload directory. var serverUrl: String = "" - // Get session + /// The current account session. var session: NCSession.Session { NCSession.shared.getSession(controller: controller) } + /// The active window scene, used for presenting banners. var windowScene: UIWindowScene? { SceneManager.shared.getWindowScene(controller: controller) } @@ -89,6 +93,7 @@ class NCAutoUploadModel: ObservableObject, ViewOnAppearHandling { // MARK: - All functions + /// Requests Photos library authorization and warns the user if background app refresh is disabled. func requestAuthorization() { PHPhotoLibrary.requestAuthorization { status in DispatchQueue.main.async { [self] in @@ -133,6 +138,7 @@ class NCAutoUploadModel: ObservableObject, ViewOnAppearHandling { } } + /// Sets the cut-off date so only photos/videos created after it are uploaded. func handleAutoUploadOnlyNew(newValue: Bool) { if newValue { autoUploadSinceDate = Date.now @@ -209,6 +215,10 @@ class NCAutoUploadModel: ObservableObject, ViewOnAppearHandling { } } + /// Returns a display title for the selected auto-upload albums. + /// + /// - Parameter autoUploadAlbumIds: The local identifiers of the selected albums. + /// - Returns: The album's localized title, "Camera Roll" for the user library, or a localized "multiple albums" string when more than one is selected. func createAlbumTitle(autoUploadAlbumIds: Set) -> String { if autoUploadAlbumIds.count == 1 { let album = PHAssetCollection.allAlbums.first(where: { autoUploadAlbumIds.first == $0.localIdentifier }) @@ -218,11 +228,13 @@ class NCAutoUploadModel: ObservableObject, ViewOnAppearHandling { } } + /// Whether any auto-upload entry exists for the current account. func existsAutoUpload() -> Bool { let autoUploadServerUrlBase = NCManageDatabase.shared.getAccountAutoUploadServerUrlBase(session: session) return NCManageDatabase.shared.existsAutoUpload(account: session.account, autoUploadServerUrlBase: autoUploadServerUrlBase) } + /// Deletes pending auto-upload transfers for the current account. func deleteAutoUploadTransfer() { Task { let autoUploadServerUrlBase = await NCManageDatabase.shared.getAccountAutoUploadServerUrlBaseAsync(session: session) @@ -230,25 +242,26 @@ class NCAutoUploadModel: ObservableObject, ViewOnAppearHandling { } } - /// Updates the auto-upload create subfolder setting. + /// Requests or revokes `Always` location authorization for background location-based auto upload. func handleLocationChange(newValue: Bool) { if let controller = self.controller { if newValue { Task { @MainActor in let result = await NCBackgroundLocationUploadManager.shared.requestAuthorizationAlwaysAsync(from: controller) - self.permissionGranted = result + self.locationAutoUploadPermissionGranted = result NCPreferences().location = result } } else { - self.permissionGranted = false + self.locationAutoUploadPermissionGranted = false NCPreferences().location = false } } } + /// Refreshes `locationAutoUploadPermissionGranted` from the current location authorization status and stored preference. func checkPermission() { let status = CLLocationManager().authorizationStatus - permissionGranted = (status == .authorizedAlways && NCPreferences().location) + locationAutoUploadPermissionGranted = (status == .authorizedAlways && NCPreferences().location) } } diff --git a/iOSClient/Settings/AutoUpload/NCAutoUploadView.swift b/iOSClient/Settings/AutoUpload/NCAutoUploadView.swift index f178959853..3c3024697a 100644 --- a/iOSClient/Settings/AutoUpload/NCAutoUploadView.swift +++ b/iOSClient/Settings/AutoUpload/NCAutoUploadView.swift @@ -7,6 +7,7 @@ import SwiftUI import UIKit /// A view that allows the user to configure the `auto upload settings for Nextcloud` +@MainActor struct NCAutoUploadView: View { @State private var reachedAnchor = false @@ -15,7 +16,11 @@ struct NCAutoUploadView: View { @State private var showUploadFolder = false @State private var showSelectAlbums = false @State private var showUploadAllPhotosWarning = false + @State private var showFocusedAutoUploadIntro = false + @State private var showFocusedAutoUploadProgress = false + @State private var openFocusedAutoUploadFinish = false @State private var startAutoUpload = false + @Environment(NCAutoUploadCounter.self) private var autoUploadCounter var body: some View { ZStack { @@ -27,8 +32,26 @@ struct NCAutoUploadView: View { } .navigationBarTitle(NSLocalizedString("_auto_upload_folder_", comment: "")) .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + VStack(spacing: 0) { + Text(NSLocalizedString("_auto_upload_folder_", comment: "")) + .font(.headline) + + if model.autoUploadStart && autoUploadCounter.isLoaded { + Text(autoUploadCounter.itemsLeftSummary) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } .onAppear { model.onViewAppear() + updateAutoUploadCounterSubscription() + } + .onDisappear { + stopAutoUploadCounterSubscription() } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in model.checkPermission() @@ -49,11 +72,72 @@ struct NCAutoUploadView: View { ConfirmAutoUploadSheet(model: model, isPresented: $showUploadAllPhotosWarning) .presentationDetents([.medium, .large]) } + .sheet(isPresented: $showFocusedAutoUploadIntro, onDismiss: { + guard openFocusedAutoUploadFinish else { return } + + openFocusedAutoUploadFinish = false + guard autoUploadCounter.hasItemsToUpload else { return } + + showFocusedAutoUploadProgress = true + }) { + NCFocusedAutoUploadIntroView { + openFocusedAutoUploadFinish = true + showFocusedAutoUploadIntro = false + } + .presentationDetents([.large]) + } + .fullScreenCover(isPresented: $showFocusedAutoUploadProgress) { + NCFocusedAutoUploadProgressView(isPresented: $showFocusedAutoUploadProgress, + account: model.session.account, + urlBase: model.session.urlBase, + userId: model.session.userId) + .environment(autoUploadCounter) + } + .onChange(of: model.autoUploadStart) { _, newValue in + if !newValue { + showFocusedAutoUploadIntro = false + showFocusedAutoUploadProgress = false + openFocusedAutoUploadFinish = false + } + updateAutoUploadCounterSubscription() + } } @ViewBuilder var autoUploadOnView: some View { Form { + if model.autoUploadStart && autoUploadCounter.hasItemsToUpload { + Section(content: { + Button { + showFocusedAutoUploadIntro = true + } label: { + HStack { + Image(systemName: "moon") + .font(.icon()) + .frame(width: 26) + .foregroundColor(Color(NCBrandColor.shared.iconImageColor)) + + Text(NSLocalizedString("_focused_auto_upload_", comment: "")) + .font(.body) + .foregroundStyle(.primary) + + Spacer() + + Image(systemName: "chevron.right") + .font(.footnote) + .fontWeight(.semibold) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + }, footer: { + Text(NSLocalizedString("_focused_auto_upload_settings_footer_", comment: "")) + .font(.footnote) + }) + } + Group { Section(content: { Button(action: { @@ -194,11 +278,11 @@ struct NCAutoUploadView: View { // Location Section(content: { - Toggle(NSLocalizedString("_enable_background_location_title_", comment: ""), isOn: $model.permissionGranted) + Toggle(NSLocalizedString("_enable_background_location_title_", comment: ""), isOn: $model.locationAutoUploadPermissionGranted) .font(.body) .tint(Color(NCBrandColor.shared.getElement(account: model.session.account))) .opacity(model.autoUploadStart ? 0.15 : 1) - .onChange(of: model.permissionGranted) { _, newValue in + .onChange(of: model.locationAutoUploadPermissionGranted) { _, newValue in model.handleLocationChange(newValue: newValue) } }, footer: { @@ -244,6 +328,17 @@ struct NCAutoUploadView: View { } }) } + + private func updateAutoUploadCounterSubscription() { + autoUploadCounter.start(account: model.session.account, + urlBase: model.session.urlBase, + userId: model.session.userId, + autoUploadStart: model.autoUploadStart) + } + + private func stopAutoUploadCounterSubscription() { + autoUploadCounter.stop() + } } @ViewBuilder @@ -377,4 +472,5 @@ struct ConfirmAutoUploadSheet: View { #Preview { NCAutoUploadView(model: NCAutoUploadModel(controller: nil), albumModel: AlbumModel(controller: nil)) + .environment(NCAutoUploadCounter()) } diff --git a/iOSClient/Settings/AutoUpload/NCFocusedAutoUploadCloudAnimation.swift b/iOSClient/Settings/AutoUpload/NCFocusedAutoUploadCloudAnimation.swift new file mode 100644 index 0000000000..53a60d9ff2 --- /dev/null +++ b/iOSClient/Settings/AutoUpload/NCFocusedAutoUploadCloudAnimation.swift @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +struct NCFocusedAutoUploadCloudAnimation: View { + let size: CGFloat + let cloudColor: Color + let arrowColor: Color + let isAnimated: Bool + + init(size: CGFloat = 176, + cloudColor: Color = .white, + arrowColor: Color = .black.opacity(0.82), + isAnimated: Bool = true) { + self.size = size + self.cloudColor = cloudColor + self.arrowColor = arrowColor + self.isAnimated = isAnimated + } + + var body: some View { + if isAnimated { + animatedCloud + } else { + staticCloud + } + } + + private var animatedCloud: some View { + TimelineView(.animation) { timeline in + cloud(progress: animationProgress(at: timeline.date), includesMotion: true) + } + .frame(width: size, height: size * 0.82) + .accessibilityHidden(true) + } + + private var staticCloud: some View { + cloud(progress: 0, includesMotion: false) + .frame(width: size, height: size * 0.82) + .accessibilityHidden(true) + } + + private func cloud(progress: Double, includesMotion: Bool) -> some View { + ZStack { + Image(systemName: includesMotion ? "icloud.fill" : "icloud") + .font(.system(size: size * 0.53, weight: .regular)) + .foregroundStyle(cloudColor) + .scaleEffect(includesMotion ? interpolate(from: 0.96, to: 1.03, progress: progress) : 1) + .offset(y: includesMotion ? interpolate(from: size * 0.03, to: -size * 0.03, progress: progress) : 0) + + Image(systemName: "arrow.up") + .font(.system(size: includesMotion ? size * 0.17 : size * 0.22, weight: .bold)) + .foregroundStyle(arrowColor) + .offset(y: includesMotion ? interpolate(from: -size * 0.02, to: -size * 0.09, progress: progress) : 0) + } + } + + private func animationProgress(at date: Date) -> Double { + let duration = 1.45 + let cycleDuration = duration * 2 + let elapsed = date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: cycleDuration) + let linearProgress = elapsed <= duration ? elapsed / duration : (cycleDuration - elapsed) / duration + + return (1 - cos(linearProgress * .pi)) / 2 + } + + private func interpolate(from start: CGFloat, to end: CGFloat, progress: Double) -> CGFloat { + start + (end - start) * CGFloat(progress) + } +} + +#Preview { + VStack(spacing: 48) { + NCFocusedAutoUploadCloudAnimation(size: 80) + + NCFocusedAutoUploadCloudAnimation(size: 80, isAnimated: false) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) +} diff --git a/iOSClient/Settings/AutoUpload/NCFocusedAutoUploadIntroView.swift b/iOSClient/Settings/AutoUpload/NCFocusedAutoUploadIntroView.swift new file mode 100644 index 0000000000..c3f6003683 --- /dev/null +++ b/iOSClient/Settings/AutoUpload/NCFocusedAutoUploadIntroView.swift @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +struct NCFocusedAutoUploadIntroView: View { + @Environment(\.dismiss) private var dismiss + + let onEnable: () -> Void + + var body: some View { + VStack(spacing: 0) { + ZStack { + Text(NSLocalizedString("_focused_auto_upload_", comment: "")) + .font(.title3) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + + HStack { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 28, weight: .regular)) + .foregroundStyle(.primary) + .frame(width: 48, height: 48) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(NSLocalizedString("_close_", comment: "")) + + Spacer() + } + } + .padding(.top, 24) + .padding(.horizontal, 24) + + Spacer(minLength: 90) + + VStack(spacing: 24) { + Text(NSLocalizedString("_focused_auto_upload_intro_heading_", comment: "")) + .font(.title2) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + .foregroundStyle(.primary) + + Text(NSLocalizedString("_focused_auto_upload_intro_message_", comment: "")) + .font(.body) + .multilineTextAlignment(.center) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading, spacing: 22) { + guidanceRow(systemImage: "wifi", textKey: "_focused_auto_upload_wifi_") + guidanceRow(systemImage: "battery.100", textKey: "_focused_auto_upload_charger_") + guidanceRow(systemImage: "arrow.down.right.and.arrow.up.left", textKey: "_focused_auto_upload_do_not_exit_") + } + .padding(.top, 10) + } + .padding(.horizontal, 36) + + Spacer(minLength: 80) + + Button { + onEnable() + } label: { + Text(NSLocalizedString("_enable_focused_auto_upload_", comment: "")) + .font(.title3) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .background( + Capsule() + .fill(Color(UIColor.darkGray)) + ) + .padding(.horizontal, 38) + .padding(.bottom, 28) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(UIColor.systemBackground)) + } + + private func guidanceRow(systemImage: String, textKey: String) -> some View { + HStack(spacing: 16) { + guidanceIcon(systemImage: systemImage) + + Text(NSLocalizedString(textKey, comment: "")) + .font(.title3) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + } + } + + @ViewBuilder + private func guidanceIcon(systemImage: String) -> some View { + Image(systemName: systemImage) + .font(.system(size: 28, weight: .regular)) + .foregroundStyle(.secondary) + .frame(width: 34) + } +} + +#Preview { + NCFocusedAutoUploadIntroView(onEnable: {}) +} diff --git a/iOSClient/Settings/AutoUpload/NCFocusedAutoUploadProgressView.swift b/iOSClient/Settings/AutoUpload/NCFocusedAutoUploadProgressView.swift new file mode 100644 index 0000000000..5e7add7bc4 --- /dev/null +++ b/iOSClient/Settings/AutoUpload/NCFocusedAutoUploadProgressView.swift @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +@MainActor +struct NCFocusedAutoUploadProgressView: View { + @Binding var isPresented: Bool + @Environment(\.scenePhase) private var scenePhase + + let account: String + let urlBase: String + let userId: String + + @State private var countdownTask: Task? + @State private var isUploadCompleted = false + @State private var secondsUntilDim = 10 + @State private var isScreenDimmed = false + @Environment(NCAutoUploadCounter.self) private var autoUploadCounter + + private let dimDelay = 10 + + var body: some View { + ZStack { + Color.black + .ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 24) { + NCFocusedAutoUploadCloudAnimation() + .padding(.bottom, 4) + + Divider() + .background(Color.white.opacity(0.15)) + .padding(.horizontal, 36) + + VStack(spacing: 6) { + Text(isUploadCompleted + ? NSLocalizedString("_focused_auto_upload_completed_", comment: "") + : NSLocalizedString("_focused_auto_upload_backing_up_", comment: "")) + .font(.largeTitle) + .fontWeight(.semibold) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + + if autoUploadCounter.isLoaded && !isUploadCompleted { + Text(autoUploadCounter.photosToBackUpMessage) + .font(.title3) + .foregroundStyle(.white.opacity(0.9)) + .multilineTextAlignment(.center) + } + } + } + + Spacer() + + if !isUploadCompleted { + Text(statusMessage) + .font(.title3) + .foregroundStyle(.white.opacity(0.9)) + .multilineTextAlignment(.center) + .padding(.horizontal, 28) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + Button { + isPresented = false + } label: { + Text(isUploadCompleted + ? NSLocalizedString("_finish_", comment: "") + : NSLocalizedString("_stop_focused_auto_upload_", comment: "")) + .font(.title3) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .background( + Capsule() + .fill(Color(UIColor.darkGray)) + ) + .padding(.horizontal, 54) + .padding(.bottom, 34) + } + + if isScreenDimmed { + Color.black + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { + wakeFocusedScreen() + } + } + } + .preferredColorScheme(.dark) + .statusBarHidden(isScreenDimmed) + .onAppear { + startFocusedMode() + } + .onDisappear { + stopFocusedMode() + } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active { + startFocusedMode() + } else { + stopFocusedMode() + } + } + .onChange(of: autoUploadCounter.count) { + updateFocusedCompletionState() + } + .onChange(of: autoUploadCounter.isLoaded) { + updateFocusedCompletionState() + } + } + + private var statusMessage: String { + return String(format: NSLocalizedString("_focused_auto_upload_countdown_", comment: ""), secondsUntilDim) + } + + private func startFocusedMode() { + guard !isUploadCompleted, !isXcodeRunningForPreviews else { + return + } + + startDimCountdown() + updateAutoUploadCounterSubscription() + } + + private func startDimCountdown() { + countdownTask?.cancel() + secondsUntilDim = dimDelay + isScreenDimmed = false + + NCFocusedAutoUploadScreenDimmer.shared.startKeepingScreenAwake() + + countdownTask = Task { @MainActor in + while secondsUntilDim > 0 { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + secondsUntilDim -= 1 + } + + NCFocusedAutoUploadScreenDimmer.shared.dimScreen() + isScreenDimmed = true + } + } + + private func stopFocusedMode() { + countdownTask?.cancel() + countdownTask = nil + stopAutoUploadCounterSubscription() + isScreenDimmed = false + NCFocusedAutoUploadScreenDimmer.shared.restoreScreen() + } + + private func wakeFocusedScreen() { + stopFocusedMode() + startFocusedMode() + } + + private func updateAutoUploadCounterSubscription() { + autoUploadCounter.start(account: account, + urlBase: urlBase, + userId: userId, + autoUploadStart: true) + updateFocusedCompletionState() + } + + private func stopAutoUploadCounterSubscription() { + autoUploadCounter.stop() + } + + private func updateFocusedCompletionState() { + guard autoUploadCounter.isLoaded else { + return + } + + if autoUploadCounter.count == 0 { + completeFocusedUpload() + } else if isUploadCompleted { + resumeFocusedUpload() + } + } + + private func completeFocusedUpload() { + guard !isUploadCompleted else { + return + } + + isUploadCompleted = true + countdownTask?.cancel() + countdownTask = nil + + if !isScreenDimmed { + NCFocusedAutoUploadScreenDimmer.shared.restoreScreen() + } + } + + private func resumeFocusedUpload() { + isUploadCompleted = false + startDimCountdown() + } +} + +#if DEBUG +#Preview { + NCFocusedAutoUploadProgressView(isPresented: .constant(true), + account: "preview", + urlBase: "https://cloud.example.com", + userId: "preview") + .environment(NCAutoUploadCounter(previewCount: 42)) +} +#endif diff --git a/iOSClient/Settings/AutoUpload/Utility/NCAutoUploadCounter.swift b/iOSClient/Settings/AutoUpload/Utility/NCAutoUploadCounter.swift new file mode 100644 index 0000000000..b0aee0e586 --- /dev/null +++ b/iOSClient/Settings/AutoUpload/Utility/NCAutoUploadCounter.swift @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Observation + +@MainActor +@Observable +final class NCAutoUploadCounter { + private(set) var count = 0 + private(set) var failedCount = 0 + private(set) var isLoaded = false + + init() {} + +#if DEBUG + init(previewCount: Int) { + self.count = previewCount + self.isLoaded = true + } +#endif + + var hasItemsToUpload: Bool { + return isLoaded && count > 0 + } + + private var itemsLeftMessage: String { + if count == 0 { + return NSLocalizedString("_auto_upload_no_new_items_to_upload_", comment: "") + } + + return String.localizedStringWithFormat(NSLocalizedString("_focused_auto_upload_items_left_", comment: ""), count) + } + + var photosToBackUpMessage: String { + return String.localizedStringWithFormat(NSLocalizedString("_focused_auto_upload_photos_to_back_up_", comment: ""), count) + } + + private var failedMessage: String { + return String.localizedStringWithFormat(NSLocalizedString("_focused_auto_upload_failed_", comment: ""), failedCount) + } + + var itemsLeftSummary: String { + if failedCount == 0 { + return itemsLeftMessage + } + + if count == 0 { + return failedMessage + } + + return itemsLeftMessage + " · " + failedMessage + } + + @ObservationIgnored private var task: Task? + @ObservationIgnored private var observer: NSObjectProtocol? + @ObservationIgnored private var account: String? + @ObservationIgnored private var autoUploadServerUrlBase: String? + + func start(account: String, + urlBase: String, + userId: String, + autoUploadStart: Bool) { + guard autoUploadStart else { + stop(reset: true) + return + } + + // Transfers badge updates this so the update tick is the same. + observer = NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: NCGlobal.shared.notificationCenterTransferCountChanged), + object: nil, + queue: .main) { [weak self] _ in + Task { @MainActor in + await self?.refresh() + } + } + + task?.cancel() + task = Task { @MainActor in + let base = await NCManageDatabase.shared.getAccountAutoUploadServerUrlBaseAsync(account: account, + urlBase: urlBase, + userId: userId) + + guard !Task.isCancelled else { + return + } + + self.account = account + self.autoUploadServerUrlBase = base + + await refresh() + } + } + + func stop(reset: Bool = false) { + task?.cancel() + task = nil + + if let observer { + NotificationCenter.default.removeObserver(observer) + self.observer = nil + } + + guard reset else { + return + } + + account = nil + autoUploadServerUrlBase = nil + count = 0 + failedCount = 0 + isLoaded = false + } + + private func refresh() async { + guard let account, let autoUploadServerUrlBase else { + return + } + + let counts = await NCManageDatabase.shared.countAutoUploadMetadatasAsync(account: account, + autoUploadServerUrlBase: autoUploadServerUrlBase) + + count = counts.pending + failedCount = counts.failed + isLoaded = true + } +} diff --git a/iOSClient/Settings/AutoUpload/Utility/NCFocusedAutoUploadScreenDimmer.swift b/iOSClient/Settings/AutoUpload/Utility/NCFocusedAutoUploadScreenDimmer.swift new file mode 100644 index 0000000000..cad8bad4d3 --- /dev/null +++ b/iOSClient/Settings/AutoUpload/Utility/NCFocusedAutoUploadScreenDimmer.swift @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit + +@MainActor +final class NCFocusedAutoUploadScreenDimmer { + static let shared = NCFocusedAutoUploadScreenDimmer() + + private var originalBrightness: CGFloat? + private var originalIdleTimerDisabled: Bool? + private var keepAwakeTask: Task? + + private init() {} + + func startKeepingScreenAwake() { + if originalIdleTimerDisabled == nil { + originalIdleTimerDisabled = UIApplication.shared.isIdleTimerDisabled + } + + UIApplication.shared.isIdleTimerDisabled = true + startKeepAwakeTask() + } + + func dimScreen() { + if originalBrightness == nil { + originalBrightness = UIScreen.main.brightness + } + + startKeepingScreenAwake() + UIScreen.main.brightness = 0 + } + + func restoreScreen() { + keepAwakeTask?.cancel() + keepAwakeTask = nil + + if let originalBrightness { + UIScreen.main.brightness = originalBrightness + } + + if let originalIdleTimerDisabled { + UIApplication.shared.isIdleTimerDisabled = originalIdleTimerDisabled + } + + originalBrightness = nil + originalIdleTimerDisabled = nil + } + + private func startKeepAwakeTask() { + guard keepAwakeTask == nil else { + return + } + + keepAwakeTask = Task { @MainActor in + while !Task.isCancelled { + UIApplication.shared.isIdleTimerDisabled = true + try? await Task.sleep(for: .seconds(1)) + } + } + } +} diff --git a/iOSClient/Settings/Settings/NCSettingsView.swift b/iOSClient/Settings/Settings/NCSettingsView.swift index a2482c78ef..2042771f85 100644 --- a/iOSClient/Settings/Settings/NCSettingsView.swift +++ b/iOSClient/Settings/Settings/NCSettingsView.swift @@ -8,6 +8,7 @@ import NextcloudKit import FirebaseCrashlytics /// Settings view for Nextcloud +@MainActor struct NCSettingsView: View { // State to control the visibility of the acknowledgements view @State private var showAcknowledgements = false @@ -28,26 +29,6 @@ struct NCSettingsView: View { var body: some View { Form { - // `Auto Upload` Section - Section(content: { - NavigationLink(destination: LazyView { - NCAutoUploadView(model: NCAutoUploadModel(controller: model.controller), albumModel: AlbumModel(controller: model.controller)) - }) { - HStack { - Image(systemName: "photo.on.rectangle.angled") - .font(.icon()) - .foregroundColor(Color(NCBrandColor.shared.iconImageColor)) - .frame(width: 39) - - Text(NSLocalizedString("_settings_autoupload_", comment: "")) - .font(.body) - } - } - }, footer: { - Text(NSLocalizedString("_autoupload_description_", comment: "")) - .font(.footnote) - }) - // `Privacy` Section Section(content: { Button(action: { diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 822dd4e1b1..77b957c141 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -23,7 +23,7 @@ "_cancel_" = "Cancel"; "_edit_" = "Edit"; "_tap_to_cancel_" = "Tap to cancel"; -"_tap_to_min_max_" = "Tap to minimize / maximize"; +"_tap_to_min_max_" = "Tap to minimize"; "_cancel_request_" = "Do you want to cancel?"; "_upload_file_" = "Upload file"; "_download_file_" = "Download file"; @@ -166,8 +166,8 @@ "_keep_both_for_all_action_title_" = "Keep both for all"; "_more_action_title_" = "More Details"; "_wait_file_preparation_" = "Preparing file upload. Please wait …"; -"_keep_active_for_upload_" = "Keep application active until upload is completed …"; -"_keep_active_for_transfers_" = "Keep application active until the transfers are completed …"; +"_keep_active_for_upload_" = "Keep application open until upload is completed …"; +"_keep_active_for_transfers_" = "Keep application open until the transfers are completed …"; "_wait_file_encryption_" = "Please wait, encrypting file…"; "_passcode_counter_fail_" = "Too many failed attempts, please wait before reattempting."; "_seconds_" = "seconds"; @@ -672,14 +672,14 @@ "_download_in_progress_" = "Download in progress …"; "_upload_in_progress_" = "Upload in progress …"; "_transfer_in_progress_" = "Transfer in progress …"; -"_in_waiting_" = "In waiting"; +"_in_waiting_" = "Scheduled"; "_in_progress_" = "In progress"; -"_in_error_" = "In error"; +"_in_error_" = "Failed"; "_retry_minutes_" = "min left to retry"; "_retry_seconds_" = "sec left to retry"; "_retry_soon_" = "retrying soon …"; -"_large_upload_tip_" = "Large files require the app to remain open until the transfer is complete"; -"_e2ee_upload_tip_" = "End-to-end files require the app to remain open until the transfer is complete"; +"_large_upload_tip_" = "Large files require the app to remain open until the transfer is complete."; +"_e2ee_upload_tip_" = "End-to-end encrypted files require the app to remain open until the transfer is complete."; "_finalizing_wait_" = "Waiting for finalization …"; "_in_this_folder_" = "In this folder"; "_no_assistant_installed_" = "Assistant is not installed on this server. Ask your administrator to install the Assistant app"; @@ -765,6 +765,20 @@ You can stop it at any time, adjust the settings, and enable it again."; "_back_up_new_photos_only_" = "Back up new photos/videos only"; "_auto_upload_all_photos_warning_title_" = "Are you sure you want to upload all photos?"; "_auto_upload_all_photos_warning_message_" = "This can take some time to process depending on the amount of photos."; +"_auto_upload_no_new_items_to_upload_" = "No new items to upload"; +"_focused_auto_upload_" = "Focused Auto Upload"; +"_focused_auto_upload_settings_footer_" = "Keep the app open and darken the screen while auto upload is backing up at higher speed."; +"_focused_auto_upload_intro_heading_" = "Keep backing up with the screen darkened."; +"_focused_auto_upload_intro_message_" = "Photos and videos will continue backing up at higher speed while the screen is darkened. During the process:"; +"_focused_auto_upload_wifi_" = "Connect to Wi-Fi"; +"_focused_auto_upload_charger_" = "Connect to charger"; +"_focused_auto_upload_do_not_exit_" = "Do not exit the app"; +"_enable_focused_auto_upload_" = "Enable Focused Auto Upload"; +"_focused_auto_upload_backing_up_" = "Backing up"; +"_focused_auto_upload_completed_" = "Upload completed!"; +"_focused_auto_upload_countdown_" = "Do not lock the screen or exit the app. The screen will turn dark in %d seconds."; +"_stop_focused_auto_upload_" = "Stop Focused Auto Upload"; +"_finish_" = "Finish"; "_item_with_same_name_already_exists_" = "An item with the same name already exists."; // MARK: Migration Multi Domains diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.stringsdict b/iOSClient/Supporting Files/en.lproj/Localizable.stringsdict index e0e01b6491..3696c67892 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.stringsdict +++ b/iOSClient/Supporting Files/en.lproj/Localizable.stringsdict @@ -18,5 +18,53 @@ %d remaining downloads allowed + _focused_auto_upload_items_left_ + + NSStringLocalizedFormatKey + %#@items@ + items + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d item left + other + %d items left + + + _focused_auto_upload_photos_to_back_up_ + + NSStringLocalizedFormatKey + %#@photos@ + photos + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + You have %d item to back up + other + You have %d items to back up + + + _focused_auto_upload_failed_ + + NSStringLocalizedFormatKey + %#@failed@ + failed + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d failed + other + %d failed + +