diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 1ba23daec8..61c096118e 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -44,6 +44,8 @@ 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 */; }; + 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 */; }; AF1A9B6527D0CC0500F17A9E /* UIAlertController+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1A9B6327D0CA1E00F17A9E /* UIAlertController+Extension.swift */; }; AF22B206277B4E4C00DAB0CC /* NCCreateFormUploadConflict.swift in Sources */ = {isa = PBXBuildFile; fileRef = F704B5E42430AA8000632F5F /* NCCreateFormUploadConflict.swift */; }; @@ -1209,6 +1211,8 @@ 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 = ""; }; + 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 = ""; }; AF22B20B277C6F4D00DAB0CC /* NCShareCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareCell.swift; sourceTree = ""; }; AF22B215277D196700DAB0CC /* NCShareExtension+DataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NCShareExtension+DataSource.swift"; sourceTree = ""; }; @@ -1261,6 +1265,7 @@ F34E1AD82ECC839100FA10C3 /* EmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiTextField.swift; sourceTree = ""; }; F34E1ADA2ECC842200FA10C3 /* NCStatusMessageModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCStatusMessageModel.swift; sourceTree = ""; }; F351D1A52D0AF24A00930F94 /* PHAssetCollection+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PHAssetCollection+Extension.swift"; sourceTree = ""; }; + F35746602F6B0D27009F9F5A /* NextcloudKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = NextcloudKit; path = ../NextcloudKit; sourceTree = SOURCE_ROOT; }; F359D8662A7D03420023F405 /* NCUtility+Exif.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCUtility+Exif.swift"; sourceTree = ""; }; F36C514D2E89393C0097E5F7 /* UIView+BlurVibrancy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+BlurVibrancy.swift"; sourceTree = ""; }; F36E64F62B9245210085ABB5 /* NCCollectionViewCommon+SelectTabBarDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+SelectTabBarDelegate.swift"; sourceTree = ""; }; @@ -2369,6 +2374,8 @@ F769454722E9F20D000A798A /* NCShareNetworking.swift */, AA8E041C2D300FDE00E7E89C /* NCShareNetworkingDelegate.swift */, F769453F22E9F077000A798A /* NCSharePaging.swift */, + AB6000002F60000100FE2775 /* NCTagEditorModel.swift */, + AB6000022F60000200FE2775 /* NCTagEditorView.swift */, F774264822EB4D0000B23912 /* NCSearchUserDropDownCell.xib */, F769453B22E9CFFF000A798A /* NCShareUserCell.xib */, AF2D7C7D2742559100ADF566 /* NCShareUserCell.swift */, @@ -3277,6 +3284,7 @@ C04E2F202A17BB4D001BAD85 /* NextcloudIntegrationTests.xctest */, C0046CDA2A17B98400D87C9D /* NextcloudUITests.xctest */, F7F1FBA62E27D13700C79E20 /* Frameworks */, + F35746602F6B0D27009F9F5A /* NextcloudKit */, ); sourceTree = ""; }; @@ -4699,6 +4707,8 @@ AA8D316F2D4123B200FE2775 /* NCShareDownloadLimitTableViewController.swift in Sources */, AA8D31702D4123B200FE2775 /* DownloadLimitViewModel.swift in Sources */, AA8D31712D4123B200FE2775 /* NCShareDownloadLimitViewController.swift in Sources */, + AB6000012F60000100FE2775 /* NCTagEditorModel.swift in Sources */, + AB6000032F60000200FE2775 /* NCTagEditorView.swift in Sources */, AF93471B27E2361E002537EE /* NCShareAdvancePermission.swift in Sources */, F77BC3ED293E528A005F2B08 /* NCConfigServer.swift in Sources */, F7A560422AE1593700BE8FD6 /* NCOperationSaveLivePhoto.swift in Sources */, diff --git a/Share/NCShareExtension+DataSource.swift b/Share/NCShareExtension+DataSource.swift index bd2e3a1b10..2893fb505d 100644 --- a/Share/NCShareExtension+DataSource.swift +++ b/Share/NCShareExtension+DataSource.swift @@ -102,7 +102,7 @@ extension NCShareExtension: UICollectionViewDataSource { cell.imageStatus.image = utility.loadImage(named: "livephoto", colors: [NCBrandColor.shared.iconImageColor2]) } - cell.setTags(tags: Array(metadata.tags)) + cell.setTags(tags: Array(metadata.tags), account: metadata.account) cell.separator.isHidden = collectionView.numberOfItems(inSection: indexPath.section) == indexPath.row + 1 diff --git a/iOSClient/Data/NCManageDatabase+Metadata.swift b/iOSClient/Data/NCManageDatabase+Metadata.swift index 572fb2bb13..371f63247c 100644 --- a/iOSClient/Data/NCManageDatabase+Metadata.swift +++ b/iOSClient/Data/NCManageDatabase+Metadata.swift @@ -754,6 +754,17 @@ extension NCManageDatabase { } } + func setMetadataTagsAsync(ocId: String, tags: [String]) async { + await core.performRealmWriteAsync { realm in + guard let metadata = realm.object(ofType: tableMetadata.self, forPrimaryKey: ocId) else { + return + } + + metadata.tags.removeAll() + metadata.tags.append(objectsIn: tags) + } + } + func moveMetadataAsync(ocId: String, serverUrlTo: String) async { await core.performRealmWriteAsync { realm in if let result = realm.objects(tableMetadata.self) diff --git a/iOSClient/Main/Collection Common/Cell/NCListCell.swift b/iOSClient/Main/Collection Common/Cell/NCListCell.swift index 771573e7c9..06b3926ea0 100755 --- a/iOSClient/Main/Collection Common/Cell/NCListCell.swift +++ b/iOSClient/Main/Collection Common/Cell/NCListCell.swift @@ -14,6 +14,9 @@ protocol NCListCellDelegate: AnyObject { } class NCListCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellMainProtocol { + private static var tagColorsByAccount: [String: [String: NKTag]] = [:] + private static var loadingTagColorsForAccounts: Set = [] + @IBOutlet weak var imageItem: UIImageView! @IBOutlet weak var imageSelect: UIImageView! @IBOutlet weak var imageStatus: UIImageView! @@ -24,8 +27,8 @@ class NCListCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellMainP @IBOutlet weak var labelInfo: UILabel! @IBOutlet weak var labelSubinfo: UILabel! @IBOutlet weak var labelInfoSeparator: UILabel! - @IBOutlet weak var tag0: UILabel! - @IBOutlet weak var tag1: UILabel! + @IBOutlet weak var tag0: PaddedAndBorderedLabel! + @IBOutlet weak var tag1: PaddedAndBorderedLabel! @IBOutlet weak var labelExtension: UILabel! @IBOutlet weak var buttonShared: UIButton! @@ -38,6 +41,8 @@ class NCListCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellMainP @IBOutlet weak var separatorHeightConstraint: NSLayoutConstraint! weak var delegate: NCListCellDelegate? + private var currentTagTokens: [String] = [] + private var currentTagAccount: String = "" // Cell Protocol var metadata: tableMetadata? { @@ -113,6 +118,8 @@ class NCListCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellMainP labelSubinfo.text = "" tag0.text = "" tag1.text = "" + currentTagTokens = [] + currentTagAccount = "" // Dynamic Type Font Configuration // @@ -258,7 +265,11 @@ class NCListCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellMainP accessibilityValue = value } - func setTags(tags: [String]) { + func setTags(tags: [String], account: String) { + currentTagTokens = tags + currentTagAccount = account + applyDefaultTagBorderStyle() + if tags.isEmpty { tag0.isHidden = true tag1.isHidden = true @@ -280,6 +291,71 @@ class NCListCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellMainP } } } + + applyTagBorderColorsIfAvailable() + loadTagColorsIfNeeded(account: account) + } + + private func applyDefaultTagBorderStyle() { + tag0.backgroundColor = .clear + tag1.backgroundColor = .clear + tag0.borderColor = .systemGray5 + tag1.borderColor = .systemGray5 + tag0.textColor = .systemGray + tag1.textColor = .systemGray + tag0.setNeedsDisplay() + tag1.setNeedsDisplay() + } + + private func applyTagBorderColorsIfAvailable() { + guard !currentTagTokens.isEmpty, + let lookup = NCListCell.tagColorsByAccount[currentTagAccount], + let firstToken = currentTagTokens.first, + let colorHex = lookup[firstToken]?.color, + let color = UIColor(hex: colorHex) else { + return + } + + tag0.borderColor = color + tag0.textColor = color + if !tag1.isHidden { + tag1.borderColor = .systemGray5 + tag1.textColor = .systemGray + } + tag0.setNeedsDisplay() + tag1.setNeedsDisplay() + } + + private func loadTagColorsIfNeeded(account: String) { + guard !account.isEmpty else { + return + } + if NCListCell.tagColorsByAccount[account] != nil || NCListCell.loadingTagColorsForAccounts.contains(account) { + return + } + + NCListCell.loadingTagColorsForAccounts.insert(account) + Task { [weak self] in + let result = await NextcloudKit.shared.getTags(account: account) + DispatchQueue.main.async { + NCListCell.loadingTagColorsForAccounts.remove(account) + guard result.error == .success, let tags = result.tags else { + return + } + + var lookup: [String: NKTag] = [:] + for tag in tags { + lookup[tag.id] = tag + lookup[tag.name] = tag + } + NCListCell.tagColorsByAccount[account] = lookup + + guard let self, self.currentTagAccount == account else { + return + } + self.applyTagBorderColorsIfAvailable() + } + } } func setIconOutlines() { @@ -509,7 +585,7 @@ extension NCCollectionViewCommon { } // TAGS - cell.setTags(tags: Array(metadata.tags)) + cell.setTags(tags: Array(metadata.tags), account: metadata.account) // SearchingMode - TAG Separator Hidden if isSearchingMode { diff --git a/iOSClient/Select/NCSelect.swift b/iOSClient/Select/NCSelect.swift index 775de939ab..64665f5624 100644 --- a/iOSClient/Select/NCSelect.swift +++ b/iOSClient/Select/NCSelect.swift @@ -455,7 +455,7 @@ extension NCSelect: UICollectionViewDataSource { } // Add TAGS - cell.setTags(tags: Array(metadata.tags)) + cell.setTags(tags: Array(metadata.tags), account: metadata.account) return cell } diff --git a/iOSClient/Share/NCShareHeader.swift b/iOSClient/Share/NCShareHeader.swift index 1e2868ac50..1d2f81946b 100644 --- a/iOSClient/Share/NCShareHeader.swift +++ b/iOSClient/Share/NCShareHeader.swift @@ -23,6 +23,8 @@ import UIKit import TagListView +import SwiftUI +import NextcloudKit class NCShareHeader: UIView { @IBOutlet weak var imageView: UIImageView! @@ -33,10 +35,14 @@ class NCShareHeader: UIView { @IBOutlet weak var fileNameTopConstraint: NSLayoutConstraint! @IBOutlet weak var tagListView: TagListView! + private var metadata = tableMetadata() + private var heightConstraintWithImage: NSLayoutConstraint? private var heightConstraintWithoutImage: NSLayoutConstraint? + private var currentTagsByToken: [String: NKTag] = [:] func setupUI(with metadata: tableMetadata) { + self.metadata = metadata.detachedCopy() let utilityFileSystem = NCUtilityFileSystem() if let image = NCUtility().getImage(ocId: metadata.ocId, etag: metadata.etag, ext: NCGlobal.shared.previewExt1024, userId: metadata.userId, urlBase: metadata.urlBase) { fullWidthImageView.image = image @@ -64,7 +70,8 @@ class NCShareHeader: UIView { info.textColor = NCBrandColor.shared.textColor2 info.text = utilityFileSystem.transformedSize(metadata.size) + ", " + NCUtility().getRelativeDateTitle(metadata.date as Date) - tagListView.addTags(Array(metadata.tags)) + refreshTags(Array(metadata.tags)) + loadTagColors() setNeedsLayout() layoutIfNeeded() @@ -75,4 +82,74 @@ class NCShareHeader: UIView { imageView.isHidden = traitCollection.verticalSizeClass != .compact } } + + func presentTagEditor(from sourceViewController: UIViewController, onApplied: (([NKTag]) -> Void)? = nil) { + let editor = NCTagEditorView( + metadata: metadata.detachedCopy(), + initialTags: Array(metadata.tags), + windowScene: sourceViewController.view.window?.windowScene, + onApplied: { [weak self] tags in + self?.metadata.tags.removeAll() + self?.metadata.tags.append(objectsIn: tags.map(\.name)) + self?.refreshTags(tags.map(\.name), tagModels: tags) + onApplied?(tags) + } + ) + let hosting = UIHostingController(rootView: editor) + hosting.title = NSLocalizedString("_tags_", comment: "") + if let sheet = hosting.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + } + sourceViewController.present(hosting, animated: true) + } + + private func refreshTags(_ tags: [String], tagModels: [NKTag]? = nil) { + if let tagModels { + var tagsByToken: [String: NKTag] = [:] + for tag in tagModels { + tagsByToken[tag.id] = tag + tagsByToken[tag.name] = tag + } + currentTagsByToken = tagsByToken + } + + tagListView.removeAllTags() + for tagToken in tags { + let matchedTag = currentTagsByToken[tagToken] + let displayName = matchedTag?.name ?? tagToken + + let tagView = tagListView.addTag(displayName) + if let colorHex = matchedTag?.color, let color = UIColor(hex: colorHex) { + tagView.tagBackgroundColor = .clear + tagView.borderColor = color + tagView.textColor = color + tagView.selectedTextColor = color + } + } + } + + private func loadTagColors() { + let account = metadata.account + let selectedTokens = Set(Array(metadata.tags)) + guard !account.isEmpty, !selectedTokens.isEmpty else { + return + } + + Task { [weak self] in + guard let self else { return } + let result = await NextcloudKit.shared.getTags(account: account) + guard result.error == .success, let allTags = result.tags else { + return + } + + let selectedTags = allTags.filter { tag in + selectedTokens.contains(tag.id) || selectedTokens.contains(tag.name) + } + + DispatchQueue.main.async { + self.refreshTags(Array(self.metadata.tags), tagModels: selectedTags) + } + } + } } diff --git a/iOSClient/Share/NCSharePaging.swift b/iOSClient/Share/NCSharePaging.swift index 53cf2dbfea..7e6ac4fd48 100644 --- a/iOSClient/Share/NCSharePaging.swift +++ b/iOSClient/Share/NCSharePaging.swift @@ -53,6 +53,14 @@ class NCSharePaging: UIViewController { navigationItem.leftBarButtonItem = UIBarButtonItem(title: NSLocalizedString("_close_", comment: ""), style: .plain, target: self, action: #selector(exitTapped(_:))) + let manageTagsAction = UIAction(title: NSLocalizedString("_edit_tags_", comment: ""), image: UIImage(systemName: "tag")) { [weak self] _ in + self?.editTagsTapped(nil) + } + + let moreButton = UIBarButtonItem(image: UIImage(systemName: "ellipsis"), style: .plain, target: nil, action: nil) + moreButton.menu = UIMenu(children: [manageTagsAction]) + navigationItem.rightBarButtonItem = moreButton + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(applicationDidEnterBackground(notification:)), name: UIApplication.didEnterBackgroundNotification, object: nil) @@ -168,6 +176,20 @@ class NCSharePaging: UIViewController { self.dismiss(animated: true, completion: nil) } + @objc func editTagsTapped(_ sender: Any?) { + guard let header = (pagingViewController.view as? NCSharePagingView)?.header else { + return + } + + header.presentTagEditor(from: self) { [weak self] tags in + guard let self else { return } + self.metadata.tags.removeAll() + self.metadata.tags.append(objectsIn: tags.map(\.name)) + self.pagingViewController.metadata.tags.removeAll() + self.pagingViewController.metadata.tags.append(objectsIn: tags.map(\.name)) + } + } + @objc func applicationDidEnterBackground(notification: Notification) { self.dismiss(animated: false, completion: nil) } diff --git a/iOSClient/Share/NCTagEditorModel.swift b/iOSClient/Share/NCTagEditorModel.swift new file mode 100644 index 0000000000..7825676431 --- /dev/null +++ b/iOSClient/Share/NCTagEditorModel.swift @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import UIKit +import NextcloudKit + +@MainActor +@Observable final class NCTagEditorModel { + var searchText: String = "" + private(set) var tags: [NKTag] = [] + private(set) var selectedTagIDs: Set = [] + private(set) var pendingNewTagNames: Set = [] + private(set) var isLoading = false + private(set) var isSaving = false + private(set) var hasLoaded = false + + private let metadata: tableMetadata + private let initialTagTokens: Set + private let windowScene: UIWindowScene? + private var initialAssignedTagIDs: Set = [] + + init(metadata: tableMetadata, initialTags: [String], windowScene: UIWindowScene?) { + self.metadata = metadata + self.initialTagTokens = Set(initialTags) + self.windowScene = windowScene + } + + var account: String { + metadata.account + } + + var filteredTags: [NKTag] { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return tags.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + return tags + .filter { $0.name.localizedCaseInsensitiveContains(trimmed) } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + var createCandidateName: String? { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + + let hasExistingTag = tags.contains { + $0.name.compare(trimmed, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + } + if hasExistingTag { + return nil + } + + let alreadyPending = pendingNewTagNames.contains { + $0.compare(trimmed, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + } + + return alreadyPending ? nil : trimmed + } + + func isSelected(_ tag: NKTag) -> Bool { + selectedTagIDs.contains(tag.id) + } + + func loadTagsIfNeeded() async { + guard !hasLoaded else { + return + } + _ = await reloadTags(keepCurrentSelection: false) + } + + func toggleSelection(for tag: NKTag) { + if selectedTagIDs.contains(tag.id) { + selectedTagIDs.remove(tag.id) + } else { + selectedTagIDs.insert(tag.id) + } + } + + func addCreateCandidateToSelection() { + guard let candidate = createCandidateName else { + return + } + pendingNewTagNames.insert(candidate) + searchText = "" + } + + var selectedTags: [NKTag] { + tags + .filter { selectedTagIDs.contains($0.id) } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + func saveChanges() async -> [NKTag]? { + guard !metadata.fileId.isEmpty else { + await showErrorBanner( + windowScene: windowScene, + text: "_error_occurred_", + errorCode: NCGlobal.shared.errorInternalError + ) + return nil + } + + isSaving = true + defer { isSaving = false } + + if !pendingNewTagNames.isEmpty { + for name in pendingNewTagNames.sorted() { + let createResult = await NextcloudKit.shared.createTag(name: name, account: metadata.account) + if createResult.error != .success { + await showErrorBanner(windowScene: windowScene, error: createResult.error) + return nil + } + } + + guard await reloadTags(keepCurrentSelection: true) else { + return nil + } + + for pendingName in pendingNewTagNames { + if let tag = tags.first(where: { + $0.name.compare(pendingName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + }) { + selectedTagIDs.insert(tag.id) + } + } + } + + let tagsToAdd = selectedTagIDs.subtracting(initialAssignedTagIDs) + let tagsToRemove = initialAssignedTagIDs.subtracting(selectedTagIDs) + + for tagID in tagsToAdd.sorted() { + let addResult = await NextcloudKit.shared.addTagToFile(tagId: tagID, fileId: metadata.fileId, account: metadata.account) + if addResult.error != .success { + await showErrorBanner(windowScene: windowScene, error: addResult.error) + return nil + } + } + + for tagID in tagsToRemove.sorted() { + let removeResult = await NextcloudKit.shared.removeTagFromFile(tagId: tagID, fileId: metadata.fileId, account: metadata.account) + if removeResult.error != .success { + await showErrorBanner(windowScene: windowScene, error: removeResult.error) + return nil + } + } + + let selectedTags = self.selectedTags + let selectedTagNames = selectedTags.map(\.name) + + await NCManageDatabase.shared.setMetadataTagsAsync(ocId: metadata.ocId, tags: selectedTagNames) + + NotificationCenter.default.postOnMainThread(name: NCGlobal.shared.notificationCenterReloadDataNCShare) + + await NCNetworking.shared.transferDispatcher.notifyAllDelegates { delegate in + delegate.transferReloadDataSource(serverUrl: self.metadata.serverUrl, requestData: false, status: nil) + } + + initialAssignedTagIDs = selectedTagIDs + pendingNewTagNames.removeAll() + + return selectedTags + } + + private func reloadTags(keepCurrentSelection: Bool) async -> Bool { + isLoading = true + defer { + isLoading = false + hasLoaded = true + } + + let result = await NextcloudKit.shared.getTags(account: metadata.account) + guard result.error == .success, let receivedTags = result.tags else { + await showErrorBanner(windowScene: windowScene, error: result.error) + return false + } + + tags = receivedTags.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + + if keepCurrentSelection { + let validTagIDs = Set(tags.map(\.id)) + selectedTagIDs = Set(selectedTagIDs.filter { validTagIDs.contains($0) }) + return true + } + + let assignedIDs = Set(tags.filter { tag in + initialTagTokens.contains(tag.id) || initialTagTokens.contains(tag.name) + }.map(\.id)) + + initialAssignedTagIDs = assignedIDs + selectedTagIDs = assignedIDs + return true + } +} diff --git a/iOSClient/Share/NCTagEditorView.swift b/iOSClient/Share/NCTagEditorView.swift new file mode 100644 index 0000000000..7a9db194d8 --- /dev/null +++ b/iOSClient/Share/NCTagEditorView.swift @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UIKit +import NextcloudKit + +struct NCTagEditorView: View { + @Environment(\.dismiss) private var dismiss + @State private var model: NCTagEditorModel + + private let onApplied: ([NKTag]) -> Void + + init(metadata: tableMetadata, initialTags: [String], windowScene: UIWindowScene?, onApplied: @escaping ([NKTag]) -> Void) { + _model = State(initialValue: NCTagEditorModel(metadata: metadata, initialTags: initialTags, windowScene: windowScene)) + self.onApplied = onApplied + } + + var body: some View { + NavigationStack { + List { + if let createCandidateName = model.createCandidateName { + Section { + Button { + model.addCreateCandidateToSelection() + } label: { + Label( + String(format: NSLocalizedString("_share_tags_create_", comment: ""), createCandidateName), + systemImage: "plus.circle.fill" + ) + } + } + } + + Section { + if model.filteredTags.isEmpty, model.createCandidateName == nil, !model.isLoading { + Text(NSLocalizedString("_share_tags_no_results_", comment: "")) + .foregroundStyle(.secondary) + } else { + ForEach(model.filteredTags, id: \.id) { tag in + Button { + model.toggleSelection(for: tag) + } label: { + HStack { + Circle() + .fill(color(for: tag)) + .frame(width: 10, height: 10) + + Text(tag.name) + .foregroundStyle(.primary) + + Spacer() + + if model.isSelected(tag) { + Image(systemName: "checkmark") + .foregroundStyle(Color(NCBrandColor.shared.getElement(account: model.account))) + } + } + } + } + } + } header: { + Text(NSLocalizedString("_tags_", comment: "")) + } + } + .listStyle(.plain) + .navigationTitle(NSLocalizedString("_tags_", comment: "")) + .searchable(text: $model.searchText, prompt: Text(NSLocalizedString("_search_", comment: ""))) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(NSLocalizedString("_cancel_", comment: "")) { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(NSLocalizedString("_done_", comment: "")) { + Task { @MainActor in + guard let selectedTags = await model.saveChanges() else { + return + } + onApplied(selectedTags) + dismiss() + } + } + .disabled(model.isSaving || model.isLoading) + } + } + .overlay { + if model.isLoading || model.isSaving { + ProgressView() + } + } + } + .task { + await model.loadTagsIfNeeded() + } + } + + private func color(for tag: NKTag) -> Color { + if let colorHex = tag.color, let color = UIColor(hex: colorHex) { + return Color(color) + } + return .secondary + } +} diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 1b9656f19a..a930cc57f1 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -86,6 +86,11 @@ "_add_folder_info_" = "Add folder info"; "_back_" = "Back"; "_search_" = "Search"; +"_search_or_create_tags" = "Search or create tag"; +"_tags_" = "Tags"; +"_share_tags_create_" = "Create \"%@\""; +"_share_tags_no_results_" = "No tags found"; +"_edit_tags_" = "Manage tags"; "_of_" = "of"; "_livephoto_save_" = "Save Live Photo to Photo Album"; "_livephoto_save_error_" = "Error during save of Live Photo.";