diff --git a/Brand/Database.swift b/Brand/Database.swift index 4c94bbaea3..f083885915 100644 --- a/Brand/Database.swift +++ b/Brand/Database.swift @@ -8,4 +8,4 @@ import Foundation // let databaseName = "nextcloud.realm" let tableAccountBackup = "tableAccountBackup.json" -let databaseSchemaVersion: UInt64 = 408 +let databaseSchemaVersion: UInt64 = 409 diff --git a/Brand/NCBrand.swift b/Brand/NCBrand.swift index c53e2e82a5..0fd9b5b6a7 100755 --- a/Brand/NCBrand.swift +++ b/Brand/NCBrand.swift @@ -326,12 +326,14 @@ final class NCBrandColor: @unchecked Sendable { return false } + /* public func getTheming(account: String?) -> UIColor { if let account, let color = self.themingColor[account] { return color } return customer } + */ public func getElement(account: String?) -> UIColor { if let account, let color = self.themingColorElement[account] { diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index ac069e22c1..72d1ca23e6 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -6129,7 +6129,7 @@ repositoryURL = "https://github.com/nextcloud/NextcloudKit"; requirement = { kind = exactVersion; - version = 7.2.8; + version = 7.2.9; }; }; F788ECC5263AAAF900ADC67F /* XCRemoteSwiftPackageReference "MarkdownKit" */ = { diff --git a/iOSClient/Data/NCManageDatabase+Trash.swift b/iOSClient/Data/NCManageDatabase+Trash.swift index c17bfa8456..de1809b04e 100644 --- a/iOSClient/Data/NCManageDatabase+Trash.swift +++ b/iOSClient/Data/NCManageDatabase+Trash.swift @@ -7,25 +7,41 @@ import UIKit import RealmSwift import NextcloudKit -class tableTrash: Object { - @objc dynamic var account = "" - @objc dynamic var classFile = "" - @objc dynamic var contentType = "" - @objc dynamic var date = NSDate() - @objc dynamic var directory: Bool = false - @objc dynamic var fileId = "" - @objc dynamic var fileName = "" - @objc dynamic var filePath = "" - @objc dynamic var hasPreview: Bool = false - @objc dynamic var iconName = "" - @objc dynamic var size: Int64 = 0 - @objc dynamic var trashbinFileName = "" - @objc dynamic var trashbinOriginalLocation = "" - @objc dynamic var trashbinDeletionTime = NSDate() - - override static func primaryKey() -> String { - return "fileId" - } +/// Represents a trash item stored in Realm. +/// +/// Each object corresponds to a file or folder in the Nextcloud trashbin, +/// associated with a specific account. +/// +/// The `identifier` is used as primary key and is built from: +/// `account + "|" + fileName`, where `fileName` includes the `.dXXXXX` suffix, +/// making each item unique. +/// +/// - `fileName`: name of the file in trash (includes `.dXXXXX`) +/// - `trashbinFileName`: original file name before deletion +/// - `trashbinOriginalLocation`: original path before deletion +/// - `classFile`: type of file (e.g. "image", "video", "document") +/// +/// This model replaces the legacy `tableTrash` schema. +typealias tableTrash = tableTrashV2 +class tableTrashV2: Object { + // Primary key: unique per account + trash item + @Persisted(primaryKey: true) var identifier: String + + @Persisted var account: String = "" + @Persisted var classFile: String = "" + @Persisted var contentType: String = "" + @Persisted var date: Date = Date() + @Persisted var directory: Bool = false + @Persisted var fileId: String = "" + @Persisted var fileName: String = "" + @Persisted var filePath: String = "" + @Persisted var hasPreview: Bool = false + @Persisted var iconName: String = "" + @Persisted var size: Int64 = 0 + @Persisted var livePhoto: Bool = false + @Persisted var trashbinFileName: String = "" + @Persisted var trashbinOriginalLocation: String = "" + @Persisted var trashbinDeletionTime: Date = Date() } extension NCManageDatabase { @@ -38,12 +54,22 @@ extension NCManageDatabase { /// - account: The account string used to associate each trash item. /// - items: An array of `NKTrash` items to be added to the database. func addTrashAsync(items: [NKTrash], account: String) async { + let itemsFiltered = filterOutVideosMatchingImages(items) + await core.performRealmWriteAsync { realm in - items.forEach { trash in + + // Delete all existing trash items for this account. + let existingItems = realm.objects(tableTrash.self) + .where { $0.account == account } + realm.delete(existingItems) + + itemsFiltered.forEach { trash in let object = tableTrash() + + object.identifier = "\(account)|\(trash.fileName)" object.account = account object.contentType = trash.contentType - object.date = trash.date as NSDate + object.date = trash.date object.directory = trash.directory object.fileId = trash.fileId object.fileName = trash.fileName @@ -51,10 +77,12 @@ extension NCManageDatabase { object.hasPreview = trash.hasPreview object.iconName = trash.iconName object.size = trash.size - object.trashbinDeletionTime = trash.trashbinDeletionTime as NSDate + object.trashbinDeletionTime = trash.trashbinDeletionTime object.trashbinFileName = trash.trashbinFileName object.trashbinOriginalLocation = trash.trashbinOriginalLocation object.classFile = trash.classFile + object.livePhoto = trash.livePhoto + realm.add(object, update: .all) } } @@ -141,4 +169,84 @@ extension NCManageDatabase { .map { tableTrash(value: $0) } } } + + // MARK: - helpers + + /// Filters out video items that have a matching image counterpart based on a shared trash suffix. + /// + /// This function is designed to handle Live Photo pairs in the trash, where both the image + /// (e.g. `.jpg`) and the video (e.g. `.mov`) share the same suffix (e.g. `.d123456`). + /// + /// The logic works as follows: + /// - Extract the suffix from each trash item file name. + /// - Detect which suffixes contain both an image and a video. + /// - Iterate through all items: + /// - If an item is a video and its suffix is shared with an image, the video is excluded. + /// - If an item is an image and its suffix is shared with a video, the image is kept and + /// marked with `isLivePhoto = true`. + /// - All other items are returned unchanged. + /// + /// - Parameter items: An array of `NKTrash` items to process. + /// - Returns: A filtered array where Live Photo videos are removed and matching images are marked as Live Photos. + func filterOutVideosMatchingImages(_ items: [NKTrash]) -> [NKTrash] { + var suffixMap: [String: (hasImage: Bool, hasVideo: Bool)] = [:] + + for item in items { + guard let suffix = trashSuffix(from: item.fileName) else { + continue + } + var entry = suffixMap[suffix] ?? (false, false) + + if item.classFile == "image" { + entry.hasImage = true + } else if item.classFile == "video" { + entry.hasVideo = true + } + + suffixMap[suffix] = entry + } + + return items.compactMap { item -> NKTrash? in + guard let suffix = trashSuffix(from: item.fileName) else { + return item + } + let entry = suffixMap[suffix] + let isLive = (entry?.hasImage == true && entry?.hasVideo == true) + + if item.classFile == "video" && isLive { + return nil + } + + if item.classFile == "image" && isLive { + var copy = item + copy.livePhoto = true + return copy + } + + return item + } + } + + /// Extracts the suffix component from a trash file name. + /// + /// The suffix is defined as the substring after the last dot (`.`) in the file name. + /// This is typically used to identify related files in the trash (e.g., Live Photo pairs), + /// where files share a common suffix such as `d123456`. + /// + /// Examples: + /// - `file.jpg.d123456` → `d123456` + /// - `video.mov.d987654` → `d987654` + /// + /// If the file name does not contain a dot or the suffix is empty, the function returns `nil`. + /// + /// - Parameter fileName: The full file name string. + /// - Returns: The extracted suffix, or `nil` if not available. + func trashSuffix(from fileName: String) -> String? { + guard let lastDot = fileName.lastIndex(of: ".") else { + return nil + } + + let suffix = String(fileName[fileName.index(after: lastDot)...]) + return suffix.isEmpty ? nil : suffix + } } diff --git a/iOSClient/Data/NCManageDatabase.swift b/iOSClient/Data/NCManageDatabase.swift index 42f1eeb380..8e27653fc9 100644 --- a/iOSClient/Data/NCManageDatabase.swift +++ b/iOSClient/Data/NCManageDatabase.swift @@ -211,6 +211,7 @@ final class NCManageDatabase: @unchecked Sendable { self.clearTable(tableMetadataTag.self) self.clearTable(tableRecommendedFiles.self) self.clearTable(tableShare.self) + self.clearTable(tableTrash.self) } func clearDatabase(account: String) { diff --git a/iOSClient/GUI/ComponentView.swift b/iOSClient/GUI/ComponentView.swift index 9a24acc557..628a642339 100644 --- a/iOSClient/GUI/ComponentView.swift +++ b/iOSClient/GUI/ComponentView.swift @@ -13,7 +13,7 @@ struct ButtonRounded: ButtonStyle { .padding(.horizontal, 40) .padding(.vertical, 10) .background(disabled ? Color(UIColor.placeholderText) : Color(NCBrandColor.shared.getElement(account: account))) - .foregroundColor(disabled ? Color(UIColor.placeholderText) : Color(NCBrandColor.shared.getText(account: account))) + .foregroundColor(disabled ? Color(UIColor.lightGray) : .white) .clipShape(Capsule()) .opacity(configuration.isPressed ? 0.5 : 1.0) } diff --git a/iOSClient/Main/Collection Common/Cell/NCCellMain.swift b/iOSClient/Main/Collection Common/Cell/NCCellMain.swift index 055b09a411..71b9ec76c6 100644 --- a/iOSClient/Main/Collection Common/Cell/NCCellMain.swift +++ b/iOSClient/Main/Collection Common/Cell/NCCellMain.swift @@ -13,6 +13,8 @@ protocol NCCellMainProtocol { var localImg: UIImageView? { get set } var statusImg: UIImageView? { get set } var infoLbl: UILabel? { get set } + + func selected(_ status: Bool, isEditMode: Bool, color: UIColor) } extension NCCellMainProtocol { diff --git a/iOSClient/Main/Collection Common/Cell/NCGridCell.swift b/iOSClient/Main/Collection Common/Cell/NCGridCell.swift index ee326110f5..57831aa19b 100644 --- a/iOSClient/Main/Collection Common/Cell/NCGridCell.swift +++ b/iOSClient/Main/Collection Common/Cell/NCGridCell.swift @@ -80,17 +80,23 @@ class NCGridCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellMainP imageItem.image = nil imageItem.layer.cornerRadius = 6 imageItem.layer.masksToBounds = true - imageSelect.isHidden = true - imageSelect.image = NCImageCache.shared.getImageCheckedYes() + imageStatus.image = nil imageFavorite.image = nil imageLocal.image = nil - labelTitle.text = "" - labelExtension.text = "" - labelExtension.isHidden = true - labelInfo.text = "" - labelSubinfo.text = "" + iconsStackView.addBlurBackground(style: .systemMaterial) + iconsStackView.layer.cornerRadius = 8 + iconsStackView.clipsToBounds = true + + imageVisualEffect.isHidden = false + imageVisualEffect.effect = nil + imageVisualEffect.alpha = 0 + imageVisualEffect.isUserInteractionEnabled = false + imageVisualEffect.backgroundColor = UIColor.white.withAlphaComponent(0.2) + + buttonMore.menu = nil + buttonMore.showsMenuAsPrimaryAction = true // Dynamic Type Font Configuration // @@ -118,30 +124,22 @@ class NCGridCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellMainP // adjustsFontForContentSizeCategory: // Enables live updates when accessibility settings change. // + labelTitle.text = "" labelTitle.font = .callout() labelTitle.adjustsFontForContentSizeCategory = true + labelExtension.text = "" + labelExtension.isHidden = true labelExtension.font = .callout() labelExtension.adjustsFontForContentSizeCategory = true + labelInfo.text = "" labelInfo.font = .footnote() labelInfo.adjustsFontForContentSizeCategory = true + labelSubinfo.text = "" labelSubinfo.font = .footnote() labelSubinfo.adjustsFontForContentSizeCategory = true - - imageVisualEffect.layer.cornerRadius = 6 - imageVisualEffect.clipsToBounds = true - imageVisualEffect.alpha = 0.5 - - iconsStackView.addBlurBackground(style: .systemMaterial) - iconsStackView.layer.cornerRadius = 8 - iconsStackView.clipsToBounds = true - - buttonMore.menu = nil - buttonMore.showsMenuAsPrimaryAction = true - - contentView.bringSubviewToFront(buttonMore) } override func snapshotView(afterScreenUpdates afterUpdates: Bool) -> UIView? { @@ -164,21 +162,17 @@ class NCGridCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellMainP buttonMore.isHidden = status } - func selected(_ status: Bool, isEditMode: Bool) { + func selected(_ status: Bool, isEditMode: Bool, color: UIColor) { if isEditMode { buttonMore.isHidden = true accessibilityCustomActions = nil } else { buttonMore.isHidden = false } - if status { - imageSelect.isHidden = false - imageSelect.image = NCImageCache.shared.getImageCheckedYes() - imageVisualEffect.isHidden = false - } else { - imageSelect.isHidden = true - imageVisualEffect.isHidden = true - } + + imageVisualEffect.alpha = status ? 1 : 0 + imageSelect.alpha = status ? 1 : 0 + imageSelect.image = NCImageCache.shared.getImageCheckedYes(color: color) } func writeInfoDateSize(date: NSDate, size: Int64) { @@ -323,10 +317,10 @@ extension NCCollectionViewCommon { // Edit mode if fileSelect.contains(metadata.ocId) { - cell.selected(true, isEditMode: isEditMode) + cell.selected(true, isEditMode: isEditMode, color: NCBrandColor.shared.getElement(account: session.account)) a11yValues.append(NSLocalizedString("_selected_", comment: "")) } else { - cell.selected(false, isEditMode: isEditMode) + cell.selected(false, isEditMode: isEditMode, color: NCBrandColor.shared.getElement(account: session.account)) } // Accessibility diff --git a/iOSClient/Main/Collection Common/Cell/NCGridCell.xib b/iOSClient/Main/Collection Common/Cell/NCGridCell.xib index bed4152326..7cb8863c41 100644 --- a/iOSClient/Main/Collection Common/Cell/NCGridCell.xib +++ b/iOSClient/Main/Collection Common/Cell/NCGridCell.xib @@ -20,22 +20,6 @@ - - - - - - - - @@ -67,16 +51,6 @@ - @@ -103,6 +77,31 @@ + + + + + + + + + + + + + + + + @@ -155,10 +154,10 @@ - + - + diff --git a/iOSClient/Main/Collection Common/Cell/NCListCell.swift b/iOSClient/Main/Collection Common/Cell/NCListCell.swift index dc0deb49a5..d89a957dc7 100755 --- a/iOSClient/Main/Collection Common/Cell/NCListCell.swift +++ b/iOSClient/Main/Collection Common/Cell/NCListCell.swift @@ -104,16 +104,19 @@ class NCListCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellMainP imageStatus.image = nil imageFavorite.image = nil imageLocal.image = nil - imageSelect.image = nil - labelTitle.text = "" - labelExtension.text = "" - labelExtension.isHidden = true - labelInfo.text = "" - labelSubinfo.text = "" - tag1.text = "" - tag2.text = "" - tagMore.text = "" + buttonShared.setImage(nil, for: .normal) + buttonShared.imageEdgeInsets = .zero + + buttonMore.setImage(nil, for: .normal) + buttonMore.menu = nil + buttonMore.showsMenuAsPrimaryAction = true + + shareContainer.isHidden = false + moreContainer.isHidden = false + + imageItemLeftConstraint.constant = 10 + separatorHeightConstraint.constant = 0.5 // Dynamic Type Font Configuration // @@ -141,40 +144,38 @@ class NCListCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellMainP // adjustsFontForContentSizeCategory: // Enables live updates when accessibility settings change. // + labelTitle.text = "" labelTitle.font = .callout() labelTitle.adjustsFontForContentSizeCategory = true + labelExtension.text = "" + labelExtension.isHidden = true labelExtension.font = .callout() labelExtension.adjustsFontForContentSizeCategory = true + labelInfo.text = "" labelInfo.font = .footnote() labelInfo.adjustsFontForContentSizeCategory = true labelInfoSeparator.font = .footnote() labelInfoSeparator.adjustsFontForContentSizeCategory = true + labelSubinfo.text = "" labelSubinfo.font = .footnote() labelSubinfo.adjustsFontForContentSizeCategory = true + tag1.text = "" + tag2.text = "" + tag1.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) tag2.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + tag1.setContentHuggingPriority(.defaultLow, for: .horizontal) tag2.setContentHuggingPriority(.defaultLow, for: .horizontal) + + tagMore.text = "" tagMore.setContentCompressionResistancePriority(.required, for: .horizontal) tagMore.setContentHuggingPriority(.required, for: .horizontal) - - buttonShared.setImage(nil, for: .normal) - buttonShared.imageEdgeInsets = .zero - - buttonMore.setImage(nil, for: .normal) - buttonMore.menu = nil - buttonMore.showsMenuAsPrimaryAction = true - - shareContainer.isHidden = false - moreContainer.isHidden = false - - imageItemLeftConstraint.constant = 10 - separatorHeightConstraint.constant = 0.5 } func setSharedAvatarImage(_ image: UIImage) { @@ -221,7 +222,7 @@ class NCListCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellMainP moreContainer.isHidden = hidden } - func selected(_ status: Bool, isEditMode: Bool) { + func selected(_ status: Bool, isEditMode: Bool, color: UIColor) { if isEditMode { imageItemLeftConstraint.constant = 45 imageSelect.isHidden = false @@ -245,11 +246,11 @@ class NCListCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellMainP blurEffectView?.backgroundColor = .lightGray blurEffectView?.frame = self.bounds blurEffectView?.autoresizingMask = [.flexibleWidth, .flexibleHeight] - imageSelect.image = NCImageCache.shared.getImageCheckedYes() + imageSelect.image = NCImageCache.shared.getImageCheckedYes(color: color) backgroundView = blurEffectView separator.isHidden = true } else { - imageSelect.image = NCImageCache.shared.getImageCheckedNo() + imageSelect.image = NCImageCache.shared.getImageCheckedNo(color: color) backgroundView = nil separator.isHidden = false } @@ -535,10 +536,10 @@ extension NCCollectionViewCommon { // Edit mode if fileSelect.contains(metadata.ocId) { - cell.selected(true, isEditMode: isEditMode) + cell.selected(true, isEditMode: isEditMode, color: NCBrandColor.shared.getElement(account: session.account)) a11yValues.append(NSLocalizedString("_selected_", comment: "")) } else { - cell.selected(false, isEditMode: isEditMode) + cell.selected(false, isEditMode: isEditMode, color: NCBrandColor.shared.getElement(account: session.account)) } // Accessibility diff --git a/iOSClient/Main/Collection Common/Cell/NCPhotoCell.swift b/iOSClient/Main/Collection Common/Cell/NCPhotoCell.swift index 5c67a1dddc..2e29d9690a 100644 --- a/iOSClient/Main/Collection Common/Cell/NCPhotoCell.swift +++ b/iOSClient/Main/Collection Common/Cell/NCPhotoCell.swift @@ -15,6 +15,12 @@ class NCPhotoCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellMain set { imageItem = newValue } } + override func awakeFromNib() { + super.awakeFromNib() + + initCell() + } + override func prepareForReuse() { super.prepareForReuse() @@ -27,25 +33,22 @@ class NCPhotoCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellMain accessibilityValue = nil imageItem.image = nil - imageSelect.isHidden = true - imageSelect.image = NCImageCache.shared.getImageCheckedYes() - imageVisualEffect.clipsToBounds = true - imageVisualEffect.alpha = 0.5 + + imageVisualEffect.isHidden = false + imageVisualEffect.effect = nil + imageVisualEffect.alpha = 0 + imageVisualEffect.isUserInteractionEnabled = false + imageVisualEffect.backgroundColor = UIColor.white.withAlphaComponent(0.2) } override func snapshotView(afterScreenUpdates afterUpdates: Bool) -> UIView? { return nil } - func selected(_ status: Bool, isEditMode: Bool) { - if status { - imageSelect.isHidden = false - imageVisualEffect.isHidden = false - imageSelect.image = NCImageCache.shared.getImageCheckedYes() - } else { - imageSelect.isHidden = true - imageVisualEffect.isHidden = true - } + func selected(_ status: Bool, isEditMode: Bool, color: UIColor) { + imageVisualEffect.alpha = status ? 1 : 0 + imageSelect.alpha = status ? 1 : 0 + imageSelect.image = NCImageCache.shared.getImageCheckedYes(color: color) } func setAccessibility(label: String, value: String) { @@ -96,9 +99,9 @@ extension NCCollectionViewCommon { // Edit mode // if fileSelect.contains(metadata.ocId) { - cell.selected(true, isEditMode: isEditMode) + cell.selected(true, isEditMode: isEditMode, color: NCBrandColor.shared.getElement(account: session.account)) } else { - cell.selected(false, isEditMode: isEditMode) + cell.selected(false, isEditMode: isEditMode, color: NCBrandColor.shared.getElement(account: session.account)) } return cell diff --git a/iOSClient/Main/Collection Common/Cell/NCPhotoCell.xib b/iOSClient/Main/Collection Common/Cell/NCPhotoCell.xib index 40878baeef..b5ec6e142b 100644 --- a/iOSClient/Main/Collection Common/Cell/NCPhotoCell.xib +++ b/iOSClient/Main/Collection Common/Cell/NCPhotoCell.xib @@ -19,13 +19,12 @@ - -