diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 573d936f33..5642b677b2 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -41,10 +41,10 @@ AA8D31702D4123B200FE2775 /* DownloadLimitViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA8D31692D4123B200FE2775 /* DownloadLimitViewModel.swift */; }; AA8D31712D4123B200FE2775 /* NCShareDownloadLimitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA8D316C2D4123B200FE2775 /* NCShareDownloadLimitViewController.swift */; }; AA8E03DA2D2ED83300E7E89C /* TransientShare.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA8E03D92D2ED83300E7E89C /* TransientShare.swift */; }; - AA8E03DC2D2FBAC200E7E89C /* DownloadLimitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA8E03DB2D2FBAAD00E7E89C /* DownloadLimitTests.swift */; }; + AA8E03DC2D2FBAC200E7E89C /* DownloadLimitUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA8E03DB2D2FBAAD00E7E89C /* DownloadLimitUITests.swift */; }; AA8E041D2D300FDE00E7E89C /* NCShareNetworkingDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA8E041C2D300FDE00E7E89C /* NCShareNetworkingDelegate.swift */; }; AAA7BC2E2D3E39F1008F1A22 /* CapabilitiesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA7BC2D2D3E39EC008F1A22 /* CapabilitiesResponse.swift */; }; - AAA7BC302D3E3B88008F1A22 /* DownloadLimitCapabilityResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA7BC2F2D3E3B83008F1A22 /* DownloadLimitCapabilityResponse.swift */; }; + AAA7BC302D3E3B88008F1A22 /* CapabilityResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA7BC2F2D3E3B83008F1A22 /* CapabilityResponse.swift */; }; 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 */; }; @@ -98,6 +98,10 @@ F314F1142A30E2DE00BC7FAB /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E8A390295DC5E0006CB2D0 /* View+Extension.swift */; }; F321DA8A2B71205A00DDA0E6 /* NCTrashSelectTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F321DA892B71205A00DDA0E6 /* NCTrashSelectTabBar.swift */; }; F32FADA92D1176E3007035E2 /* UIButton+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32FADA82D1176DE007035E2 /* UIButton+Extension.swift */; }; + F3374A812D64AB9F002A38F9 /* StatusInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3374A802D64AB9E002A38F9 /* StatusInfo.swift */; }; + F3374A842D64AC31002A38F9 /* AssistantLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3374A832D64AC2C002A38F9 /* AssistantLabelStyle.swift */; }; + F3374A942D674454002A38F9 /* AssistantUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3374A932D674454002A38F9 /* AssistantUITests.swift */; }; + F3374A962D6744A4002A38F9 /* BaseUIXCTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3374A952D6744A4002A38F9 /* BaseUIXCTestCase.swift */; }; F33918C42C7CD8F2002D9AA1 /* FileNameValidator+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F33918C32C7CD8F2002D9AA1 /* FileNameValidator+Extensions.swift */; }; F33918C52C7CD8F2002D9AA1 /* FileNameValidator+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F33918C32C7CD8F2002D9AA1 /* FileNameValidator+Extensions.swift */; }; F33918C62C7CD8F2002D9AA1 /* FileNameValidator+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F33918C32C7CD8F2002D9AA1 /* FileNameValidator+Extensions.swift */; }; @@ -187,7 +191,7 @@ F39298972A3B12CB00509762 /* BaseNCMoreCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39298962A3B12CB00509762 /* BaseNCMoreCell.swift */; }; F3A047972BD2668800658E7B /* NCAssistantEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A0478F2BD2668800658E7B /* NCAssistantEmptyView.swift */; }; F3A047982BD2668800658E7B /* NCAssistantCreateNewTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A047912BD2668800658E7B /* NCAssistantCreateNewTask.swift */; }; - F3A047992BD2668800658E7B /* NCAssistantTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A047932BD2668800658E7B /* NCAssistantTask.swift */; }; + F3A047992BD2668800658E7B /* NCAssistantModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A047932BD2668800658E7B /* NCAssistantModel.swift */; }; F3A0479A2BD2668800658E7B /* NCAssistantTaskDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A047952BD2668800658E7B /* NCAssistantTaskDetail.swift */; }; F3A0479B2BD2668800658E7B /* NCAssistant.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A047962BD2668800658E7B /* NCAssistant.swift */; }; F3A0479E2BD268B500658E7B /* PopupView in Frameworks */ = {isa = PBXBuildFile; productRef = F3A0479D2BD268B500658E7B /* PopupView */; }; @@ -1266,11 +1270,11 @@ AA8D316B2D4123B200FE2775 /* NCShareDownloadLimitTableViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareDownloadLimitTableViewControllerDelegate.swift; sourceTree = ""; }; AA8D316C2D4123B200FE2775 /* NCShareDownloadLimitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareDownloadLimitViewController.swift; sourceTree = ""; }; AA8E03D92D2ED83300E7E89C /* TransientShare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransientShare.swift; sourceTree = ""; }; - AA8E03DB2D2FBAAD00E7E89C /* DownloadLimitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadLimitTests.swift; sourceTree = ""; }; + AA8E03DB2D2FBAAD00E7E89C /* DownloadLimitUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadLimitUITests.swift; sourceTree = ""; }; AA8E041C2D300FDE00E7E89C /* NCShareNetworkingDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareNetworkingDelegate.swift; sourceTree = ""; }; AA8E041E2D3114E200E7E89C /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; AAA7BC2D2D3E39EC008F1A22 /* CapabilitiesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapabilitiesResponse.swift; sourceTree = ""; }; - AAA7BC2F2D3E3B83008F1A22 /* DownloadLimitCapabilityResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadLimitCapabilityResponse.swift; sourceTree = ""; }; + AAA7BC2F2D3E3B83008F1A22 /* CapabilityResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapabilityResponse.swift; sourceTree = ""; }; AABD0C862D5F58C400F009E6 /* Server.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Server.sh; sourceTree = ""; }; AABD0C892D5F67A200F009E6 /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = ""; }; AABD0C9A2D5F73FA00F009E6 /* Placeholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Placeholder.swift; sourceTree = ""; }; @@ -1321,6 +1325,10 @@ F310B1EE2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCViewerMedia+VisionKit.swift"; sourceTree = ""; }; F321DA892B71205A00DDA0E6 /* NCTrashSelectTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCTrashSelectTabBar.swift; sourceTree = ""; }; F32FADA82D1176DE007035E2 /* UIButton+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+Extension.swift"; sourceTree = ""; }; + F3374A802D64AB9E002A38F9 /* StatusInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusInfo.swift; sourceTree = ""; }; + F3374A832D64AC2C002A38F9 /* AssistantLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantLabelStyle.swift; sourceTree = ""; }; + F3374A932D674454002A38F9 /* AssistantUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistantUITests.swift; sourceTree = ""; }; + F3374A952D6744A4002A38F9 /* BaseUIXCTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseUIXCTestCase.swift; sourceTree = ""; }; F33918C32C7CD8F2002D9AA1 /* FileNameValidator+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileNameValidator+Extensions.swift"; sourceTree = ""; }; F33EE6F12BF4C9B200CA1A51 /* PKCS12.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PKCS12.swift; sourceTree = ""; }; F343A4B22A1E01FF00DDA874 /* PHAsset+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PHAsset+Extension.swift"; sourceTree = ""; }; @@ -1335,7 +1343,7 @@ F39298962A3B12CB00509762 /* BaseNCMoreCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseNCMoreCell.swift; sourceTree = ""; }; F3A0478F2BD2668800658E7B /* NCAssistantEmptyView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCAssistantEmptyView.swift; sourceTree = ""; }; F3A047912BD2668800658E7B /* NCAssistantCreateNewTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCAssistantCreateNewTask.swift; sourceTree = ""; }; - F3A047932BD2668800658E7B /* NCAssistantTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCAssistantTask.swift; sourceTree = ""; }; + F3A047932BD2668800658E7B /* NCAssistantModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCAssistantModel.swift; sourceTree = ""; }; F3A047952BD2668800658E7B /* NCAssistantTaskDetail.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCAssistantTaskDetail.swift; sourceTree = ""; }; F3A047962BD2668800658E7B /* NCAssistant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCAssistant.swift; sourceTree = ""; }; F3BB464C2A39ADCC00461F6E /* NCMoreAppSuggestionsCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = NCMoreAppSuggestionsCell.xib; sourceTree = ""; }; @@ -2077,7 +2085,7 @@ children = ( AAA7BC2D2D3E39EC008F1A22 /* CapabilitiesResponse.swift */, AA3C85F12D394B3600F74F12 /* DownloadLimitResponse.swift */, - AAA7BC2F2D3E3B83008F1A22 /* DownloadLimitCapabilityResponse.swift */, + AAA7BC2F2D3E3B83008F1A22 /* CapabilityResponse.swift */, AA3C85ED2D36BCCB00F74F12 /* SharesResponse.swift */, AA3C85EA2D36BBF400F74F12 /* OCSResponse.swift */, ); @@ -2129,9 +2137,11 @@ C0046CDB2A17B98400D87C9D /* NextcloudUITests */ = { isa = PBXGroup; children = ( + F3374A952D6744A4002A38F9 /* BaseUIXCTestCase.swift */, AABD0C882D5F631600F009E6 /* Extensions */, AA3C85E92D36BBDE00F74F12 /* UITestBackend */, - AA8E03DB2D2FBAAD00E7E89C /* DownloadLimitTests.swift */, + AA8E03DB2D2FBAAD00E7E89C /* DownloadLimitUITests.swift */, + F3374A932D674454002A38F9 /* AssistantUITests.swift */, AA74AA962D3172CE00BE3458 /* UITestError.swift */, ); path = NextcloudUITests; @@ -2157,14 +2167,24 @@ path = Tests; sourceTree = ""; }; + F3374A7F2D64AB40002A38F9 /* Components */ = { + isa = PBXGroup; + children = ( + F3374A832D64AC2C002A38F9 /* AssistantLabelStyle.swift */, + F3374A802D64AB9E002A38F9 /* StatusInfo.swift */, + F3A0478F2BD2668800658E7B /* NCAssistantEmptyView.swift */, + ); + path = Components; + sourceTree = ""; + }; F3A0478E2BD2668800658E7B /* Assistant */ = { isa = PBXGroup; children = ( + F3A047962BD2668800658E7B /* NCAssistant.swift */, F3A047902BD2668800658E7B /* Create Task */, - F3A047922BD2668800658E7B /* Models */, F3A047942BD2668800658E7B /* Task Detail */, - F3A047962BD2668800658E7B /* NCAssistant.swift */, - F3A0478F2BD2668800658E7B /* NCAssistantEmptyView.swift */, + F3374A7F2D64AB40002A38F9 /* Components */, + F3A047922BD2668800658E7B /* Models */, ); path = Assistant; sourceTree = ""; @@ -2180,7 +2200,7 @@ F3A047922BD2668800658E7B /* Models */ = { isa = PBXGroup; children = ( - F3A047932BD2668800658E7B /* NCAssistantTask.swift */, + F3A047932BD2668800658E7B /* NCAssistantModel.swift */, ); path = Models; sourceTree = ""; @@ -4090,15 +4110,17 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - AAA7BC302D3E3B88008F1A22 /* DownloadLimitCapabilityResponse.swift in Sources */, + AAA7BC302D3E3B88008F1A22 /* CapabilityResponse.swift in Sources */, + F3374A942D674454002A38F9 /* AssistantUITests.swift in Sources */, AA3C85EB2D36BBFB00F74F12 /* OCSResponse.swift in Sources */, AA74AA972D3172D100BE3458 /* UITestError.swift in Sources */, AA3C85E82D36B08C00F74F12 /* UITestBackend.swift in Sources */, - AA8E03DC2D2FBAC200E7E89C /* DownloadLimitTests.swift in Sources */, + AA8E03DC2D2FBAC200E7E89C /* DownloadLimitUITests.swift in Sources */, AABD0C8A2D5F67A400F009E6 /* XCUIElement.swift in Sources */, AA3C85EE2D36BCCE00F74F12 /* SharesResponse.swift in Sources */, AAA7BC2E2D3E39F1008F1A22 /* CapabilitiesResponse.swift in Sources */, AA3C85F22D394B3A00F74F12 /* DownloadLimitResponse.swift in Sources */, + F3374A962D6744A4002A38F9 /* BaseUIXCTestCase.swift in Sources */, F37208812BAB5979006B5430 /* TestConstants.swift in Sources */, AABD0C9B2D5F73FC00F009E6 /* Placeholder.swift in Sources */, ); @@ -4504,6 +4526,7 @@ F71CD6CA2930D7B1006C95C1 /* NCApplicationHandle.swift in Sources */, F3754A7D2CF87D600009312E /* SetupPasscodeView.swift in Sources */, F73EF7D72B0226080087E6E9 /* NCManageDatabase+Tip.swift in Sources */, + F3374A842D64AC31002A38F9 /* AssistantLabelStyle.swift in Sources */, F74BAE172C7E2F4E0028D4FA /* FileProviderDomain.swift in Sources */, F76882402C0DD30B001CF441 /* ViewOnAppear.swift in Sources */, F790110E21415BF600D7B136 /* NCViewerRichDocument.swift in Sources */, @@ -4573,6 +4596,7 @@ F77A697D250A0FBC00FF1708 /* NCCollectionViewCommon+Menu.swift in Sources */, F7BF9D822934CA21009EE9A6 /* NCManageDatabase+LayoutForView.swift in Sources */, AA8D31662D411FA100FE2775 /* NCShareDateCell.swift in Sources */, + F3374A812D64AB9F002A38F9 /* StatusInfo.swift in Sources */, AF7E504E27A2D8FF00B5E4AF /* UIBarButton+Extension.swift in Sources */, AA8D31682D41224800FE2775 /* NCShareToggleCell.swift in Sources */, F7A846DE2BB01ACB0024816F /* NCTrashCellProtocol.swift in Sources */, @@ -4714,7 +4738,7 @@ AF3FDCC22796ECC300710F60 /* NCTrash+CollectionView.swift in Sources */, F70D7C3725FFBF82002B9E34 /* NCCollectionViewCommon.swift in Sources */, F76D364628A4F8BF00214537 /* NCActivityIndicator.swift in Sources */, - F3A047992BD2668800658E7B /* NCAssistantTask.swift in Sources */, + F3A047992BD2668800658E7B /* NCAssistantModel.swift in Sources */, F76882322C0DD1E7001CF441 /* NCAutoUploadView.swift in Sources */, F36E64F72B9245210085ABB5 /* NCCollectionViewCommon+SelectTabBar.swift in Sources */, F79A65C62191D95E00FF6DCC /* NCSelect.swift in Sources */, @@ -6155,8 +6179,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/nextcloud/NextcloudKit"; requirement = { - kind = exactVersion; - version = 6.0.3; + branch = "assistant-v2"; + kind = branch; }; }; F788ECC5263AAAF900ADC67F /* XCRemoteSwiftPackageReference "MarkdownKit" */ = { diff --git a/Share/NCShareExtension+DataSource.swift b/Share/NCShareExtension+DataSource.swift index f8dfabb3c5..0589dd9f27 100644 --- a/Share/NCShareExtension+DataSource.swift +++ b/Share/NCShareExtension+DataSource.swift @@ -154,7 +154,7 @@ extension NCShareExtension: UICollectionViewDataSource { cell.imageItem.image = NCImageCache.shared.getFolder(account: metadata.account) } - cell.labelInfo.text = utility.dateDiff(metadata.date as Date) + cell.labelInfo.text = utility.getRelativeDateTitle(metadata.date as Date) let lockServerUrl = utilityFileSystem.stringAppendServerUrl(metadata.serverUrl, addFileName: metadata.fileName) let tableDirectory = self.database.getTableDirectory(predicate: NSPredicate(format: "account == %@ AND serverUrl == %@", session.account, lockServerUrl)) diff --git a/Tests/NextcloudUITests/AssistantUITests.swift b/Tests/NextcloudUITests/AssistantUITests.swift new file mode 100644 index 0000000000..bce341aed9 --- /dev/null +++ b/Tests/NextcloudUITests/AssistantUITests.swift @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Iva Horn +// SPDX-License-Identifier: GPL-3.0-or-later + +import XCTest + +/// +/// User interface tests for the download limits management on shares. +/// +@MainActor +final class AssistantUITests: BaseUIXCTestCase { + let taskInputCreated = "TestTaskCreated" + UUID().uuidString + let taskInputRetried = "TestTaskRetried" + UUID().uuidString + let taskInputToEdit = "TestTaskToEdit" + UUID().uuidString + let taskInputDeleted = "TestTaskDeleted" + UUID().uuidString + + // MARK: - Lifecycle + + override func setUp() async throws { + try await super.setUp() + continueAfterFailure = false + + // Handle alerts presented by the system. + addUIInterruptionMonitor(withDescription: "Allow Notifications", for: "Allow") + addUIInterruptionMonitor(withDescription: "Save Password", for: "Not Now") + + // Launch the app. + app = XCUIApplication() + app.launchArguments = ["UI_TESTING"] + app.launch() + + try await logIn() + + // Set up test backend communication. + backend = UITestBackend() + + try await backend.assertCapability(true, capability: \.assistant) + } + + /// + /// Leads to the Assistant screen. + /// + private func goToAssistant() { + let button = app.tabBars["Tab Bar"].buttons["More"] + guard button.await() else { return } + button.tap() + + let talkStaticText = app.tables.staticTexts["Assistant"] + talkStaticText.tap() + } + + private func createTask(input: String) { + app.navigationBars["Assistant"].buttons["CreateButton"].tap() + + let inputTextEditor = app.textViews["InputTextEditor"] + inputTextEditor.await() + inputTextEditor.typeText(input) + app.navigationBars["New Free text to text prompt task"].buttons["Create"].tap() + } + + private func retryTask() { + let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0) + cell.staticTexts[taskInputRetried].press(forDuration: 2); + + let retryButton = app.buttons["TaskRetryContextMenu"] + XCTAssertTrue(retryButton.waitForExistence(timeout: 2), "Edit button not found in context menu") + retryButton.tap() + } + + private func editTask() { + let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0) + cell.staticTexts[taskInputToEdit].press(forDuration: 2); + + let editButton = app.buttons["TaskEditContextMenu"] + XCTAssertTrue(editButton.waitForExistence(timeout: 2), "Edit button not found in context menu") + editButton.tap() + + app.textViews["InputTextEditor"].typeText("Edited") + app.navigationBars["Edit Free text to text prompt task"].buttons["Edit"].tap() + } + + private func deleteTask() { + let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0) + cell.staticTexts[taskInputDeleted].press(forDuration: 2); + + let deleteButton = app.buttons["TaskDeleteContextMenu"] + XCTAssertTrue(deleteButton.waitForExistence(timeout: 2), "Edit button not found in context menu") + deleteButton.tap() + + app.sheets.scrollViews.otherElements.buttons["Delete"].tap() + } + + // MARK: - Tests + + func testCreateAssistantTask() async throws { + goToAssistant() + + createTask(input: taskInputCreated) + + pullToRefresh() + + try await aMoment() + + let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0) + XCTAssert(cell.staticTexts[taskInputCreated].exists) + } + + func testRetryAssistantTask() async throws { + goToAssistant() + + createTask(input: taskInputRetried) + + retryTask() + + pullToRefresh() + + try await aMoment() + + let matchingElements = app.collectionViews.cells.staticTexts.matching(identifier: taskInputRetried) + print(app.collectionViews.staticTexts.debugDescription) + XCTAssertEqual(matchingElements.count, 2, "Expected 2 elements") + } + + func testEditAssistantTask() async throws { + goToAssistant() + + createTask(input: taskInputToEdit) + + editTask() + + pullToRefresh() + + try await aMoment() + + let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0) + XCTAssert(cell.staticTexts[taskInputToEdit + "Edited"].exists) + } + + func testDeleteAssistantTask() async throws { + goToAssistant() + + createTask(input: taskInputDeleted) + + deleteTask() + + pullToRefresh() + + try await aMoment() + + let cell = app.collectionViews.children(matching: .cell).element(boundBy: 0) + XCTAssert(!cell.staticTexts[taskInputDeleted].exists) + } +} diff --git a/Tests/NextcloudUITests/BaseUIXCTestCase.swift b/Tests/NextcloudUITests/BaseUIXCTestCase.swift new file mode 100644 index 0000000000..5b54c30c5c --- /dev/null +++ b/Tests/NextcloudUITests/BaseUIXCTestCase.swift @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import XCTest + +@MainActor +class BaseUIXCTestCase: XCTestCase { + var app: XCUIApplication! + + /// + /// The Nextcloud server API abstraction object. + /// + var backend: UITestBackend! + + /// + /// Generic convenience method to define user interface interruption monitors. + /// + /// This is called every time an alert from outside the app's user interface is presented (in example system prompt about saving a password). + /// Then the button is tapped defined by the given `label`. + /// + /// - Parameters: + /// - description: The human readable description for the monitor to create. + /// - label: The localized text on the alert action to tap. + /// + /// + func addUIInterruptionMonitor(withDescription description: String, for label: String) { + addUIInterruptionMonitor(withDescription: description) { alert in + let button = alert.buttons[label] + + if button.exists { + button.tap() + return true + } + + return false + } + } + + /// + /// Let the current `Task` rest for 2 seconds. + /// + /// Some asynchronous background activities like the follow up request to define a download limit have no effect on the visible user interface. + /// Hence their outcome can only be assumed after a brief period of time. + /// + func aSmallMoment() async throws { + try await Task.sleep(for: .seconds(2)) + } + + /// + /// Let the current `Task` rest for ``TestConstants/controlExistenceTimeout``. + /// + /// Some asynchronous background activities like the follow up request to define a download limit have no effect on the visible user interface. + /// Hence their outcome can only be assumed after a brief period of time. + /// + func aMoment() async throws { + try await Task.sleep(for: .seconds(TestConstants.controlExistenceTimeout)) + } + + /// + /// Automation of the sign-in, if required. + /// + /// + func logIn() async throws { + guard app.buttons["login"].exists else { + return + } + + app.buttons["login"].tap() + + let serverAddressTextField = app.textFields["serverAddress"].firstMatch + guard serverAddressTextField.await() else { return } + + serverAddressTextField.tap() + serverAddressTextField.typeText(TestConstants.server) + + app.buttons["submitServerAddress"].tap() + + let webView = app.webViews.firstMatch + + guard webView.await() else { + throw UITestError.waitForExistence(webView) + } + + let loginButton = webView.buttons["Log in"] + + if loginButton.await() { + loginButton.tap() + } + + let usernameTextField = webView.textFields.firstMatch + + if usernameTextField.await() { + guard usernameTextField.await() else { return } + usernameTextField.tap() + usernameTextField.typeText(TestConstants.username) + + let passwordSecureTextField = webView.secureTextFields.firstMatch + passwordSecureTextField.tap() + passwordSecureTextField.typeText(TestConstants.password) + + webView.buttons.firstMatch.tap() + } + + try await aSmallMoment() + + let grantButton = webView.buttons["Grant access"] + + guard grantButton.await() else { + throw UITestError.waitForExistence(grantButton) + } + + grantButton.tap() + grantButton.awaitInexistence() + + app.buttons["accountSwitcher"].await() + + try await aSmallMoment() + } + + /// + /// Pull to refresh on the first found collection view to reveal the new file on the server. + /// + func pullToRefresh(file: StaticString = #file, line: UInt = #line) { + let cell = app.collectionViews.firstMatch.staticTexts.firstMatch + + guard cell.exists else { + XCTFail("Apparently no collection view cell is visible!", file: file, line: line) + return + } + + let start = cell.coordinate(withNormalizedOffset: CGVectorMake(0, 0)) + let finish = cell.coordinate(withNormalizedOffset: CGVectorMake(0, 20)) + + start.press(forDuration: 0.2, thenDragTo: finish) + } +} diff --git a/Tests/NextcloudUITests/DownloadLimitTests.swift b/Tests/NextcloudUITests/DownloadLimitUITests.swift similarity index 78% rename from Tests/NextcloudUITests/DownloadLimitTests.swift rename to Tests/NextcloudUITests/DownloadLimitUITests.swift index b1debaf14a..975fe1f865 100644 --- a/Tests/NextcloudUITests/DownloadLimitTests.swift +++ b/Tests/NextcloudUITests/DownloadLimitUITests.swift @@ -7,17 +7,8 @@ import XCTest /// /// User interface tests for the download limits management on shares. /// -/// > To Do: Check whether this can be converted to Swift Testing. -/// @MainActor -final class DownloadLimitTests: XCTestCase { - var app: XCUIApplication! - - /// - /// The Nextcloud server API abstraction object. - /// - var backend: UITestBackend! - +final class DownloadLimitUITests: BaseUIXCTestCase { /// /// Name of the file to work with. /// @@ -26,122 +17,6 @@ final class DownloadLimitTests: XCTestCase { /// let testFileName = "_Xcode UI Test Subject.md" - // MARK: - Helpers - - /// - /// Generic convenience method to define user interface interruption monitors. - /// - /// This is called every time an alert from outside the app's user interface is presented (in example system prompt about saving a password). - /// Then the button is tapped defined by the given `label`. - /// - /// - Parameters: - /// - description: The human readable description for the monitor to create. - /// - label: The localized text on the alert action to tap. - /// - /// > Important: This is a candidate for outsourcing into a dedicated library, if not NextcloudKit. - /// - func addUIInterruptionMonitor(withDescription description: String, for label: String) { - addUIInterruptionMonitor(withDescription: description) { alert in - let button = alert.buttons[label] - - if button.exists { - button.tap() - return true - } - - return false - } - } - - /// - /// Let the current `Task` rest for ``TestConstants/controlExistenceTimeout``. - /// - /// Some asynchronous background activities like the follow up request to define a download limit have no effect on the visible user interface. - /// Hence their outcome can only be assumed after a brief period of time. - /// - /// The odd name may stick out but reads natural in an `try await aMoment()`. - /// - func aMoment() async throws { - try await Task.sleep(for: .seconds(TestConstants.controlExistenceTimeout)) - } - - /// - /// Pull to refresh on the first found collection view to reveal the new file on the server. - /// - func pullToRefresh(file: StaticString = #file, line: UInt = #line) { - let cell = app.collectionViews.firstMatch.staticTexts.firstMatch - - guard cell.exists else { - XCTFail("Apparently no collection view cell is visible!", file: file, line: line) - return - } - - let start = cell.coordinate(withNormalizedOffset: CGVectorMake(0, 0)) - let finish = cell.coordinate(withNormalizedOffset: CGVectorMake(0, 20)) - - start.press(forDuration: 0.2, thenDragTo: finish) - } - - /// - /// Automation of the sign-in, if required. - /// - /// > Important: This is a candidate for outsourcing into a dedicated library, if not NextcloudKit. - /// - func logIn() throws { - guard app.buttons["login"].exists else { - return - } - - app.buttons["login"].tap() - - let serverAddressTextField = app.textFields["serverAddress"].firstMatch - guard serverAddressTextField.await() else { return } - - serverAddressTextField.tap() - serverAddressTextField.typeText(TestConstants.server) - - app.buttons["submitServerAddress"].tap() - - let webView = app.webViews.firstMatch - - guard webView.await() else { - throw UITestError.waitForExistence(webView) - } - - let loginButton = webView.buttons["Log in"] - - if loginButton.await() { - loginButton.tap() - } - - let usernameTextField = webView.textFields.firstMatch - - if usernameTextField.await() { - guard usernameTextField.await() else { return } - usernameTextField.tap() - usernameTextField.typeText(TestConstants.username) - - let passwordSecureTextField = webView.secureTextFields.firstMatch - passwordSecureTextField.tap() - passwordSecureTextField.typeText(TestConstants.password) - - webView.buttons.firstMatch.tap() - } - - let grantButton = webView.buttons["Grant access"] - - guard grantButton.await() else { - throw UITestError.waitForExistence(grantButton) - } - - grantButton.tap() - grantButton.awaitInexistence() - - // Switch back from Safari to our app. - app.activate() - app.buttons["accountSwitcher"].await() - } - // MARK: - Lifecycle override func setUp() async throws { @@ -157,12 +32,12 @@ final class DownloadLimitTests: XCTestCase { app.launchArguments = ["UI_TESTING"] app.launch() - try logIn() + try await logIn() // Set up test backend communication. backend = UITestBackend() - try await backend.assertDownloadLimitCapability(true) + try await backend.assertCapability(true, capability: \.downloadLimit) try await backend.delete(testFileName) try await backend.prepareTestFile(testFileName) } diff --git a/Tests/NextcloudUITests/UITestBackend/Responses/CapabilitiesResponse.swift b/Tests/NextcloudUITests/UITestBackend/Responses/CapabilitiesResponse.swift index 425afc1b68..13d02ba5b0 100644 --- a/Tests/NextcloudUITests/UITestBackend/Responses/CapabilitiesResponse.swift +++ b/Tests/NextcloudUITests/UITestBackend/Responses/CapabilitiesResponse.swift @@ -12,9 +12,11 @@ struct CapabilitiesResponse: Decodable { struct CapabilitiesResponseCapabilitiesComponent: Decodable { enum CodingKeys: String, CodingKey { case downloadLimit = "downloadlimit" + case assistant = "assistant" } - let downloadLimit: DownloadLimitCapabilityResponse? + let downloadLimit: CapabilityResponse? + let assistant: CapabilityResponse? } let capabilities: CapabilitiesResponseCapabilitiesComponent diff --git a/Tests/NextcloudUITests/UITestBackend/Responses/DownloadLimitCapabilityResponse.swift b/Tests/NextcloudUITests/UITestBackend/Responses/CapabilityResponse.swift similarity index 58% rename from Tests/NextcloudUITests/UITestBackend/Responses/DownloadLimitCapabilityResponse.swift rename to Tests/NextcloudUITests/UITestBackend/Responses/CapabilityResponse.swift index 262330be60..b2504cc66e 100644 --- a/Tests/NextcloudUITests/UITestBackend/Responses/DownloadLimitCapabilityResponse.swift +++ b/Tests/NextcloudUITests/UITestBackend/Responses/CapabilityResponse.swift @@ -3,9 +3,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later /// -/// The schema for the `downloadlimit` capability response model embedded in ``CapabilitiesResponse``. +/// Capability response for a given capability.. /// -struct DownloadLimitCapabilityResponse: Decodable { +struct CapabilityResponse: Decodable { /// /// The download capability is enabled. /// diff --git a/Tests/NextcloudUITests/UITestBackend/UITestBackend.swift b/Tests/NextcloudUITests/UITestBackend/UITestBackend.swift index 8d84b2d2d7..d7069e842a 100644 --- a/Tests/NextcloudUITests/UITestBackend/UITestBackend.swift +++ b/Tests/NextcloudUITests/UITestBackend/UITestBackend.swift @@ -106,7 +106,7 @@ class UITestBackend { /// /// Assert the (in)availability of the download limit capability on the server. /// - func assertDownloadLimitCapability(_ expectation: Bool, file: StaticString = #file, line: UInt = #line) async throws { + func assertCapability(_ expectation: Bool, capability: KeyPath, file: StaticString = #file, line: UInt = #line) async throws { let request = makeOCSRequest(path: "cloud/capabilities") let (data, info) = try await urlSession.data(for: request) let statusCode = (info as! HTTPURLResponse).statusCode @@ -117,7 +117,7 @@ class UITestBackend { } let response = try jsonDecoder.decode(OCSResponse.self, from: data) - let reality = response.data.capabilities.downloadLimit?.enabled ?? false + let reality = response.data.capabilities[keyPath: capability]?.enabled ?? false XCTAssertEqual(expectation, reality, file: file, line: line) } diff --git a/Tests/Server.sh b/Tests/Server.sh index 9d2a851495..3c5f4d0687 100755 --- a/Tests/Server.sh +++ b/Tests/Server.sh @@ -15,11 +15,22 @@ docker run \ --detach \ --name $CONTAINER_NAME \ --publish 8080:80 \ + -e BRANCH=$SERVER_VERSION \ ghcr.io/nextcloud/continuous-integration-shallow-server:latest # Wait a moment until the server is ready. -sleep 2 +echo "Please wait until the server is provisioned…" +sleep 20 # Enable File Download Limit App. docker exec $CONTAINER_NAME su www-data -c "git clone --depth 1 -b $SERVER_VERSION https://github.com/nextcloud/files_downloadlimit.git /var/www/html/apps/files_downloadlimit/" docker exec $CONTAINER_NAME su www-data -c "php /var/www/html/occ app:enable files_downloadlimit" + +# Enable Assistant and Testing app +docker exec $CONTAINER_NAME su www-data -c "php /var/www/html/occ app:enable assistant" +docker exec $CONTAINER_NAME su www-data -c "php /var/www/html/occ app:enable testing" + +#Testing app generates fake Assitant responses via cronjob. Reduce cronjob downtime so it's quicker. +docker exec $CONTAINER_NAME su www-data -c "set -e; while true; do php /var/www/html/occ background-job:worker -v -t 10 \"OC\TaskProcessing\SynchronousBackgroundJob\"; done" + +echo "Server provisioning done." diff --git a/Tests/TestConstants.swift b/Tests/TestConstants.swift index 62e697e470..1cd3fd6901 100644 --- a/Tests/TestConstants.swift +++ b/Tests/TestConstants.swift @@ -13,7 +13,7 @@ enum TestConstants { /// /// The default number of seconds to wait for the appearance of user interface controls during user interface tests. /// - static let controlExistenceTimeout: Double = 60 + static let controlExistenceTimeout: Double = 10 /// /// The full base URL for the server to run against. diff --git a/Widget/Files/FilesData.swift b/Widget/Files/FilesData.swift index e60ce369aa..5788b0d2af 100644 --- a/Widget/Files/FilesData.swift +++ b/Widget/Files/FilesData.swift @@ -204,7 +204,7 @@ func getFilesDataEntry(configuration: AccountIntent?, isPreview: Bool, displaySi } // SUBTITLE - let subTitle = utility.dateDiff(file.date as Date) + " · " + utilityFileSystem.transformedSize(file.size) + let subTitle = utility.getRelativeDateTitle(file.date as Date) + " · " + utilityFileSystem.transformedSize(file.size) // URL: nextcloud://open-file?path=Talk/IMG_0000123.jpg&user=marinofaggiana&link=https://cloud.nextcloud.com/f/123 guard var path = utilityFileSystem.getPath(path: file.path, user: file.user, fileName: file.fileName).urlEncoded else { continue } diff --git a/iOSClient/Activity/NCActivity.swift b/iOSClient/Activity/NCActivity.swift index 665e9e947f..8e59f3bd96 100644 --- a/iOSClient/Activity/NCActivity.swift +++ b/iOSClient/Activity/NCActivity.swift @@ -245,7 +245,7 @@ extension NCActivity: UITableViewDataSource { cell.labelUser.text = comment.actorDisplayName cell.labelUser.textColor = NCBrandColor.shared.textColor // Date - cell.labelDate.text = utility.dateDiff(comment.creationDateTime as Date) + cell.labelDate.text = utility.getRelativeDateTitle(comment.creationDateTime as Date) cell.labelDate.textColor = .lightGray // Message cell.labelMessage.text = comment.message @@ -353,7 +353,7 @@ extension NCActivity: UITableViewDataSource { $0.color = UIColor.lightGray } - subject += "\n" + "" + utility.dateDiff(activity.date as Date) + "" + subject += "\n" + "" + utility.getRelativeDateTitle(activity.date as Date) + "" cell.subject.attributedText = subject.set(style: StyleGroup(base: normal, ["bold": bold, "date": date])) } diff --git a/iOSClient/Assistant/Components/AssistantLabelStyle.swift b/iOSClient/Assistant/Components/AssistantLabelStyle.swift new file mode 100644 index 0000000000..5941fb0713 --- /dev/null +++ b/iOSClient/Assistant/Components/AssistantLabelStyle.swift @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +struct CustomLabelStyle: LabelStyle { + var spacing: Double = 5 + + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: spacing) { + configuration.icon + configuration.title + } + } +} diff --git a/iOSClient/Assistant/NCAssistantEmptyView.swift b/iOSClient/Assistant/Components/NCAssistantEmptyView.swift similarity index 95% rename from iOSClient/Assistant/NCAssistantEmptyView.swift rename to iOSClient/Assistant/Components/NCAssistantEmptyView.swift index d8781d2c91..d24cca06ca 100644 --- a/iOSClient/Assistant/NCAssistantEmptyView.swift +++ b/iOSClient/Assistant/Components/NCAssistantEmptyView.swift @@ -9,7 +9,7 @@ import SwiftUI struct NCAssistantEmptyView: View { - @EnvironmentObject var model: NCAssistantTask + @EnvironmentObject var model: NCAssistantModel let titleKey, subtitleKey: String var body: some View { diff --git a/iOSClient/Assistant/Components/StatusInfo.swift b/iOSClient/Assistant/Components/StatusInfo.swift new file mode 100644 index 0000000000..2fb660f0f6 --- /dev/null +++ b/iOSClient/Assistant/Components/StatusInfo.swift @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import NextcloudKit + +struct StatusInfo: View { + let task: AssistantTask + var showStatusText = false + + var body: some View { + HStack { + Label( + title: { + let text = (showStatusText && task.statusInfo.stringKey != "_assistant_task_completed_") ? "(\(NSLocalizedString(task.statusInfo.stringKey, comment: "")))" : "" + + Text("\(task.statusDate) \(text)") + .font(.callout) + .foregroundStyle(.secondary) + }, + icon: { + Image(systemName: task.statusInfo.imageSystemName) + .renderingMode(.original) + .font(Font.system(.body).weight(.light)) + } + ) + .padding(.top, 1) + .labelStyle(CustomLabelStyle()) + } + } +} diff --git a/iOSClient/Assistant/Create Task/NCAssistantCreateNewTask.swift b/iOSClient/Assistant/Create Task/NCAssistantCreateNewTask.swift index 953c6bbf19..27156c5aad 100644 --- a/iOSClient/Assistant/Create Task/NCAssistantCreateNewTask.swift +++ b/iOSClient/Assistant/Create Task/NCAssistantCreateNewTask.swift @@ -9,10 +9,11 @@ import SwiftUI struct NCAssistantCreateNewTask: View { - @EnvironmentObject var model: NCAssistantTask + @EnvironmentObject var model: NCAssistantModel @State var text = "" @FocusState private var inFocus: Bool @Environment(\.presentationMode) var presentationMode + var editMode = false var body: some View { VStack { @@ -32,6 +33,7 @@ struct NCAssistantCreateNewTask: View { .transparentScrolling() .background(Color(NCBrandColor.shared.textColor2).opacity(0.1)) .focused($inFocus) + .accessibilityIdentifier("InputTextEditor") } .background(Color(NCBrandColor.shared.textColor2).opacity(0.1)) .clipShape(.rect(cornerRadius: 8)) @@ -41,11 +43,11 @@ struct NCAssistantCreateNewTask: View { model.scheduleTask(input: text) presentationMode.wrappedValue.dismiss() }, label: { - Text(NSLocalizedString("_create_", comment: "")) + Text(NSLocalizedString(editMode ? "_edit_" : "_create_", comment: "")) }) .disabled(text.isEmpty) } - .navigationTitle(String(format: NSLocalizedString("_new_task_", comment: ""), model.selectedType?.name ?? "")) + .navigationTitle(String(format: NSLocalizedString(editMode ? "_edit_task_" : "_new_task_", comment: ""), model.selectedType?.name ?? "")) .navigationBarTitleDisplayMode(.inline) .padding() .onAppear { @@ -55,13 +57,14 @@ struct NCAssistantCreateNewTask: View { } #Preview { - let model = NCAssistantTask(controller: nil) - + let model = NCAssistantModel(controller: nil) + return NCAssistantCreateNewTask() .environmentObject(model) .onAppear { model.loadDummyData() - }} + } +} private extension View { func transparentScrolling() -> some View { diff --git a/iOSClient/Assistant/Models/NCAssistantModel.swift b/iOSClient/Assistant/Models/NCAssistantModel.swift new file mode 100644 index 0000000000..c81c5e53a8 --- /dev/null +++ b/iOSClient/Assistant/Models/NCAssistantModel.swift @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import UIKit +import NextcloudKit +import SwiftUI + +class NCAssistantModel: ObservableObject { + @Published var types: [TaskTypeData] = [] + @Published var filteredTasks: [AssistantTask] = [] + @Published var selectedType: TaskTypeData? + @Published var selectedTask: AssistantTask? + + @Published var hasError: Bool = false + @Published var isLoading: Bool = false + @Published var isRefreshing: Bool = false + @Published var controller: NCMainTabBarController? + + private var tasks: [AssistantTask] = [] + + private let session: NCSession.Session + + private let useV2: Bool + + init(controller: NCMainTabBarController?) { + self.controller = controller + session = NCSession.shared.getSession(controller: controller) + useV2 = NCCapabilities.shared.getCapabilities(account: session.account).capabilityServerVersionMajor >= NCGlobal.shared.nextcloudVersion30 + // useV2 = false + loadAllTypes() + } + + func refresh() { + isRefreshing = true + loadAllTasks(type: selectedType) + } + + func filterTasks(ofType type: TaskTypeData?) { + if let type { + self.filteredTasks = tasks.filter({ $0.type == type.id }) + } else { + self.filteredTasks = tasks + } + + self.filteredTasks = filteredTasks.sorted(by: { $0.completionExpectedAt ?? 0 > $1.completionExpectedAt ?? 0 }) + } + + func selectTaskType(_ type: TaskTypeData?) { + selectedType = type + + filteredTasks.removeAll() + loadAllTasks(type: type) + } + + func selectTask(_ task: AssistantTask) { + selectedTask = task + isLoading = true + + if useV2 { + NextcloudKit.shared.textProcessingGetTasksV2(taskType: task.type ?? "", account: session.account, completion: { _, _, _, error in + handle(task: task, error: error) + }) + } else { + NextcloudKit.shared.textProcessingGetTask(taskId: Int(task.id), account: session.account) { _, task, _, error in + guard let task else { return } + let taskV2 = NKTextProcessingTask.toV2(tasks: [task]).tasks.first + handle(task: taskV2, error: error) + } + } + + func handle(task: AssistantTask?, error: NKError?) { + self.isLoading = false + + if error != .success { + self.hasError = true + return + } + + self.selectedTask = task + } + } + + func scheduleTask(input: String) { + isLoading = true + + if useV2 { + guard let selectedType else { return } + NextcloudKit.shared.textProcessingScheduleV2(input: input, taskType: selectedType, account: session.account) { _, task, _, error in + handle(task: task, error: error) + } + } else { + NextcloudKit.shared.textProcessingSchedule(input: input, typeId: selectedType?.id ?? "", identifier: "assistant", account: session.account) { _, task, _, error in + guard let task, let taskV2 = NKTextProcessingTask.toV2(tasks: [task]).tasks.first else { return } + handle(task: taskV2, error: error) + } + } + + func handle(task: AssistantTask?, error: NKError?) { + self.isLoading = false + + if error != .success { + self.hasError = true + return + } + + guard let task else { return } + + self.tasks.insert(task, at: 0) + self.filteredTasks.insert(task, at: 0) + } + } + + func deleteTask(_ task: AssistantTask) { + isLoading = true + + if useV2 { + NextcloudKit.shared.textProcessingDeleteTaskV2(taskId: task.id, account: session.account) { _, _, error in + handle(task: task, error: error) + } + } else { + NextcloudKit.shared.textProcessingDeleteTask(taskId: Int(task.id), account: session.account) { _, _, _, error in + handle(task: task, error: error) + } + } + + func handle(task: AssistantTask, error: NKError?) { + self.isLoading = false + + if error != .success { + self.hasError = true + return + } + + self.tasks.removeAll(where: { $0.id == task.id }) + self.filteredTasks.removeAll(where: { $0.id == task.id }) + } + } + + func shareTask(_ task: AssistantTask) { + let activityVC = UIActivityViewController(activityItems: [(task.input?.input ?? "") + "\n\n" + (task.output?.output ?? "")], applicationActivities: nil) + controller?.presentedViewController?.present(activityVC, animated: true, completion: nil) // presentedViewController = the UIHostingController presenting the Assistant + } + + private func loadAllTypes() { + isLoading = true + + if useV2 { + NextcloudKit.shared.textProcessingGetTypesV2(account: session.account) { _, types, _, error in + handle(types: types, error: error) + } + } else { + NextcloudKit.shared.textProcessingGetTypes(account: session.account) { _, types, _, error in + guard let types else { return } + let typesV2 = NKTextProcessingTaskType.toV2(type: types).types + + handle(types: typesV2, error: error) + } + } + + func handle(types: [TaskTypeData]?, error: NKError) { + self.isLoading = false + + if error != .success { + self.hasError = true + return + } + + guard let types else { return } + + self.types = types + + if self.selectedType == nil { + self.selectTaskType(types.first) + } + + self.loadAllTasks(type: selectedType) + } + } + + private func loadAllTasks(appId: String = "assistant", type: TaskTypeData?) { + isLoading = true + + if useV2 { + NextcloudKit.shared.textProcessingGetTasksV2(taskType: type?.id ?? "", account: session.account) { _, tasks, _, error in + guard let tasks = tasks?.tasks.filter({ $0.appId == "assistant" }) else { return } + handle(tasks: tasks, error: error) + } + } else { + NextcloudKit.shared.textProcessingTaskList(appId: appId, account: session.account) { _, tasks, _, error in + guard let tasks else { return } + handle(tasks: NKTextProcessingTask.toV2(tasks: tasks).tasks, error: error) + } + } + + func handle(tasks: [AssistantTask], error: NKError?) { + isLoading = false + isRefreshing = false + + if error != .success { + self.hasError = true + return + } + + self.tasks = tasks + self.filterTasks(ofType: self.selectedType) + } + } +} + +#if DEBUG +extension NCAssistantModel { + public func loadDummyData() { + let loremIpsum = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + + var tasks: [AssistantTask] = [] + + for _ in 1...10 { + tasks.append(AssistantTask(id: 1, type: "", status: "", userId: "", appId: "", input: .init(input: loremIpsum), output: .init(output: loremIpsum), completionExpectedAt: 1712666412, progress: nil, lastUpdated: nil, scheduledAt: nil, endedAt: nil)) + } + + self.types = [ + TaskTypeData(id: "1", name: "Free Prompt", description: "", inputShape: nil, outputShape: nil), + TaskTypeData(id: "2", name: "Summarize", description: "", inputShape: nil, outputShape: nil), + TaskTypeData(id: "3", name: "Generate headline", description: "", inputShape: nil, outputShape: nil), + TaskTypeData(id: "4", name: "Reformulate", description: "", inputShape: nil, outputShape: nil) + ] + + self.tasks = tasks + self.filteredTasks = tasks + self.selectedType = types[0] + self.selectedTask = filteredTasks[0] + } +} +#endif + +extension AssistantTask { + struct StatusInfo { + let stringKey, imageSystemName: String + } + + var statusInfo: StatusInfo { + return switch status { + case "0", "STATUS_UNKNOWN": StatusInfo(stringKey: "_assistant_task_unknown_", imageSystemName: "questionmark.circle") + case "1", "STATUS_SCHEDULED": StatusInfo(stringKey: "_assistant_task_scheduled_", imageSystemName: "clock.badge") + case "2", "STATUS_RUNNING": StatusInfo(stringKey: "_assistant_task_in_progress_", imageSystemName: "arrow.2.circlepath") + case "3", "STATUS_SUCCESSFUL": StatusInfo(stringKey: "_assistant_task_completed_", imageSystemName: "checkmark.circle") + case "4", "STATUS_FAILED": StatusInfo(stringKey: "_assistant_task_failed_", imageSystemName: "exclamationmark.circle") + default: StatusInfo(stringKey: "_assistant_task_unknown_", imageSystemName: "questionmark.circle") + } + } + + var statusDate: String { + return NCUtility().getRelativeDateTitle(.init(timeIntervalSince1970: TimeInterval((lastUpdated ?? completionExpectedAt) ?? 0))) + } +} diff --git a/iOSClient/Assistant/Models/NCAssistantTask.swift b/iOSClient/Assistant/Models/NCAssistantTask.swift deleted file mode 100644 index ceaf400b52..0000000000 --- a/iOSClient/Assistant/Models/NCAssistantTask.swift +++ /dev/null @@ -1,191 +0,0 @@ -// -// NCAssistantModel.swift -// Nextcloud -// -// Created by Milen on 08.04.24. -// Copyright © 2024 Marino Faggiana. All rights reserved. -// - -import Foundation -import UIKit -import NextcloudKit -import SwiftUI - -class NCAssistantTask: ObservableObject { - @Published var types: [NKTextProcessingTaskType] = [] - @Published var filteredTasks: [NKTextProcessingTask] = [] - @Published var selectedType: NKTextProcessingTaskType? - @Published var selectedTask: NKTextProcessingTask? - @Published var hasError: Bool = false - @Published var isLoading: Bool = false - @Published var controller: NCMainTabBarController? - - private var tasks: [NKTextProcessingTask] = [] - private let excludedTypeIds = ["OCA\\ContextChat\\TextProcessing\\ContextChatTaskType"] - private var session: NCSession.Session { - NCSession.shared.getSession(controller: controller) - } - - init(controller: NCMainTabBarController?) { - self.controller = controller - load() - } - - func load() { - loadAllTypes() - } - - func filterTasks(ofType type: NKTextProcessingTaskType?) { - if let type { - self.filteredTasks = tasks.filter({ $0.type == type.id }) - } else { - self.filteredTasks = tasks - } - - self.filteredTasks = filteredTasks.sorted(by: { $0.completionExpectedAt ?? 0 > $1.completionExpectedAt ?? 0 }) - } - - func selectTaskType(_ type: NKTextProcessingTaskType?) { - selectedType = type - filterTasks(ofType: self.selectedType) - } - - func selectTask(_ task: NKTextProcessingTask) { - selectedTask = task - guard let id = task.id else { return } - isLoading = true - - NextcloudKit.shared.textProcessingGetTask(taskId: id, account: session.account) { _, task, _, error in - self.isLoading = false - - if error != .success { - self.hasError = true - return - } - - self.selectedTask = task - } - } - - func scheduleTask(input: String) { - isLoading = true - - NextcloudKit.shared.textProcessingSchedule(input: input, typeId: selectedType?.id ?? "", identifier: "assistant", account: session.account) { _, task, _, error in - self.isLoading = false - - if error != .success { - self.hasError = true - return - } - - guard let task else { return } - - withAnimation { - self.tasks.insert(task, at: 0) - self.filteredTasks.insert(task, at: 0) - } - } - } - - func deleteTask(_ task: NKTextProcessingTask) { - guard let id = task.id else { return } - isLoading = true - - NextcloudKit.shared.textProcessingDeleteTask(taskId: id, account: session.account) { _, task, _, error in - self.isLoading = false - - if error != .success { - self.hasError = true - return - } - - withAnimation { - self.tasks.removeAll(where: { $0.id == task?.id }) - self.filteredTasks.removeAll(where: { $0.id == task?.id }) - } - - } - } - - private func loadAllTypes() { - isLoading = true - - NextcloudKit.shared.textProcessingGetTypes(account: session.account) { _, types, _, error in - self.isLoading = false - - if error != .success { - self.hasError = true - return - } - - guard let filteredTypes = types?.filter({ !self.excludedTypeIds.contains($0.id ?? "")}), !filteredTypes.isEmpty else { return } - - withAnimation { - self.types = filteredTypes - } - - if self.selectedType == nil { - self.selectTaskType(filteredTypes.first) - } - - self.loadAllTasks() - } - } - - private func loadAllTasks(appId: String = "assistant") { - isLoading = true - - NextcloudKit.shared.textProcessingTaskList(appId: appId, account: session.account) { _, tasks, _, error in - self.isLoading = false - - if error != .success { - self.hasError = true - return - } - - guard let tasks = tasks else { return } - self.tasks = tasks - self.filterTasks(ofType: self.selectedType) - } - } -} - -extension NCAssistantTask { - public func loadDummyData() { - let loremIpsum = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." - - var tasks: [NKTextProcessingTask] = [] - - for index in 1...10 { - tasks.append(NKTextProcessingTask(id: index, type: "OCP\\TextProcessing\\FreePromptTaskType", status: index, userId: "christine", appId: "assistant", input: loremIpsum, output: loremIpsum, identifier: "", completionExpectedAt: 1712666412)) - } - - self.types = [ - NKTextProcessingTaskType(id: "1", name: "Free Prompt", description: ""), - NKTextProcessingTaskType(id: "2", name: "Summarize", description: ""), - NKTextProcessingTaskType(id: "3", name: "Generate headline", description: ""), - NKTextProcessingTaskType(id: "4", name: "Reformulate", description: "") - ] - self.tasks = tasks - self.filteredTasks = tasks - self.selectedType = types[0] - self.selectedTask = filteredTasks[0] - - } -} - -extension NKTextProcessingTask { - struct StatusInfo { - let stringKey, imageSystemName: String - } - - var statusInfo: StatusInfo { - return switch status { - case 1: StatusInfo(stringKey: "_assistant_task_scheduled_", imageSystemName: "clock") - case 2: StatusInfo(stringKey: "_assistant_task_in_progress_", imageSystemName: "clock.badge") - case 3: StatusInfo(stringKey: "_assistant_task_completed_", imageSystemName: "checkmark.circle") - case 4: StatusInfo(stringKey: "_assistant_task_failed_", imageSystemName: "exclamationmark.circle") - default: StatusInfo(stringKey: "_assistant_task_unknown_", imageSystemName: "questionmark.circle") - } - } -} diff --git a/iOSClient/Assistant/NCAssistant.swift b/iOSClient/Assistant/NCAssistant.swift index cfe7fc492b..1cb45104b1 100644 --- a/iOSClient/Assistant/NCAssistant.swift +++ b/iOSClient/Assistant/NCAssistant.swift @@ -11,8 +11,7 @@ import NextcloudKit import PopupView struct NCAssistant: View { - @EnvironmentObject var model: NCAssistantTask - @State var presentNewTaskDialog = false + @EnvironmentObject var model: NCAssistantModel @State var input = "" @Environment(\.presentationMode) var presentationMode @@ -21,20 +20,24 @@ struct NCAssistant: View { ZStack { TaskList() + if model.isLoading, !model.isRefreshing { + ProgressView() + .controlSize(.regular) + } + if model.types.isEmpty, !model.isLoading { NCAssistantEmptyView(titleKey: "_no_types_", subtitleKey: "_no_types_subtitle_") } else if model.filteredTasks.isEmpty, !model.isLoading { NCAssistantEmptyView(titleKey: "_no_tasks_", subtitleKey: "_create_task_subtitle_") } + } .toolbar { ToolbarItem(placement: .topBarLeading) { Button(action: { presentationMode.wrappedValue.dismiss() }) { - Image(systemName: "xmark") - .font(Font.system(.body).weight(.light)) - .foregroundStyle(Color(NCBrandColor.shared.iconImageColor)) + Text("_close_") } } ToolbarItem(placement: .topBarTrailing) { @@ -44,24 +47,16 @@ struct NCAssistant: View { .foregroundStyle(Color(NCBrandColor.shared.iconImageColor)) } .disabled(model.selectedType == nil) + .accessibilityIdentifier("CreateButton") } } .navigationBarTitleDisplayMode(.inline) .navigationTitle(NSLocalizedString("_assistant_", comment: "")) .frame(maxWidth: .infinity, maxHeight: .infinity) .safeAreaInset(edge: .top, spacing: -10) { - ScrollViewReader { scrollProxy in - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - ForEach(model.types, id: \.id) { type in - TypeButton(taskType: type, scrollProxy: scrollProxy) - } - } - .padding(20) - .frame(height: 50) - } - } + TypeList() } + } .navigationViewStyle(.stack) .popup(isPresented: $model.hasError) { @@ -81,7 +76,7 @@ struct NCAssistant: View { } #Preview { - let model = NCAssistantTask(controller: nil) + let model = NCAssistantModel(controller: nil) return NCAssistant() .environmentObject(model) @@ -91,24 +86,89 @@ struct NCAssistant: View { } struct TaskList: View { - @EnvironmentObject var model: NCAssistantTask + @EnvironmentObject var model: NCAssistantModel + @State var presentEditTask = false + @State var showDeleteConfirmation = false + + @State var taskToEdit: AssistantTask? + @State var taskToDelete: AssistantTask? var body: some View { List(model.filteredTasks, id: \.id) { task in - TaskItem(task: task) + TaskItem(showDeleteConfirmation: $showDeleteConfirmation, taskToDelete: $taskToDelete, task: task) + .contextMenu { + Button { + model.shareTask(task) + } label: { + Label { + Text("_share_") + } icon: { + Image(systemName: "square.and.arrow.up") + } + } + + Button { + model.scheduleTask(input: task.input?.input ?? "") + } label: { + Label { + Text("_retry_") + } icon: { + Image(systemName: "arrow.trianglehead.clockwise") + } + } + .accessibilityIdentifier("TaskRetryContextMenu") + + Button { + taskToEdit = task + presentEditTask = true + } label: { + Label { + Text("_edit_") + } icon: { + Image(systemName: "pencil") + } + } + .accessibilityIdentifier("TaskEditContextMenu") + + Button(role: .destructive) { + taskToDelete = task + showDeleteConfirmation = true + } label: { + Label { + Text("_delete_") + } icon: { + Image(systemName: "trash") + } + } + .accessibilityIdentifier("TaskDeleteContextMenu") + } + .accessibilityIdentifier("TaskContextMenu") } .if(!model.types.isEmpty) { view in view.refreshable { - model.load() + model.refresh() + } + } + .confirmationDialog("", isPresented: $showDeleteConfirmation) { + Button(NSLocalizedString("_delete_", comment: ""), role: .destructive) { + withAnimation { + guard let taskToDelete else { return } + model.deleteTask(taskToDelete) + } + } + } + .sheet(isPresented: $presentEditTask) { [taskToEdit] in + NavigationView { + NCAssistantCreateNewTask(text: taskToEdit?.input?.input ?? "", editMode: true) } } } } struct TypeButton: View { - @EnvironmentObject var model: NCAssistantTask + @EnvironmentObject var model: NCAssistantModel - let taskType: NKTextProcessingTaskType? + let taskType: TaskTypeData? var scrollProxy: ScrollViewProxy var body: some View { @@ -123,12 +183,13 @@ struct TypeButton: View { } .padding(.horizontal) .padding(.vertical, 7) - .foregroundStyle(model.selectedType?.id == taskType?.id ? .white : .primary) + .foregroundStyle(.primary) + .background(.ultraThinMaterial) .if(model.selectedType?.id == taskType?.id) { view in - view.background(Color(NCBrandColor.shared.getElement(account: model.controller?.account))) - } - .if(model.selectedType?.id != taskType?.id) { view in - view.background(.ultraThinMaterial) + view + .foregroundStyle(.white) + .background(Color(NCBrandColor.shared.getElement(account: model.controller?.account))) + } .clipShape(.capsule) .overlay( @@ -140,20 +201,29 @@ struct TypeButton: View { } struct TaskItem: View { - @EnvironmentObject var model: NCAssistantTask - @State var showDeleteConfirmation = false - let task: NKTextProcessingTask + @EnvironmentObject var model: NCAssistantModel + @Binding var showDeleteConfirmation: Bool + @Binding var taskToDelete: AssistantTask? + var task: AssistantTask var body: some View { NavigationLink(destination: NCAssistantTaskDetail(task: task)) { - VStack(alignment: .leading) { - Text(task.input ?? "") - .lineLimit(4) + VStack(alignment: .leading, spacing: 8) { + Text(task.input?.input ?? "") + .lineLimit(1) + + if let output = task.output?.output, !output.isEmpty { + Text(output) + .lineLimit(1) + .foregroundStyle(.secondary) + } HStack { Label( title: { - Text(NSLocalizedString(task.statusInfo.stringKey, comment: "")) + Text(task.statusDate) + .font(.callout) + .foregroundStyle(.secondary) }, icon: { Image(systemName: task.statusInfo.imageSystemName) @@ -161,40 +231,35 @@ struct TaskItem: View { .font(Font.system(.body).weight(.light)) } ) - .padding(.top, 1) .labelStyle(CustomLabelStyle()) - - if let completionExpectedAt = task.completionExpectedAt { - Text(NCUtility().dateDiff(.init(timeIntervalSince1970: TimeInterval(completionExpectedAt)))) - .frame(maxWidth: .infinity, alignment: .trailing) - .foregroundStyle(.tertiary) - } } } .swipeActions { Button(NSLocalizedString("_delete_", comment: "")) { + taskToDelete = task showDeleteConfirmation = true } .tint(.red) } - .confirmationDialog("", isPresented: $showDeleteConfirmation) { - Button(NSLocalizedString("_delete_", comment: ""), role: .destructive) { - withAnimation { - model.deleteTask(task) - } - } - } } } } -private struct CustomLabelStyle: LabelStyle { - var spacing: Double = 5 +struct TypeList: View { + @EnvironmentObject var model: NCAssistantModel - func makeBody(configuration: Configuration) -> some View { - HStack(spacing: spacing) { - configuration.icon - configuration.title + var body: some View { + ScrollViewReader { scrollProxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(model.types, id: \.id) { type in + TypeButton(taskType: type, scrollProxy: scrollProxy) + } + } + .padding(20) + .frame(height: 50) + } + .background(.ultraThinMaterial) } } } diff --git a/iOSClient/Assistant/Task Detail/NCAssistantTaskDetail.swift b/iOSClient/Assistant/Task Detail/NCAssistantTaskDetail.swift index 3544fc6fc8..ce487732ee 100644 --- a/iOSClient/Assistant/Task Detail/NCAssistantTaskDetail.swift +++ b/iOSClient/Assistant/Task Detail/NCAssistantTaskDetail.swift @@ -10,8 +10,8 @@ import SwiftUI import NextcloudKit struct NCAssistantTaskDetail: View { - @EnvironmentObject var model: NCAssistantTask - let task: NKTextProcessingTask + @EnvironmentObject var model: NCAssistantModel + let task: AssistantTask var body: some View { ZStack(alignment: .bottom) { @@ -19,6 +19,13 @@ struct NCAssistantTaskDetail: View { BottomDetailsBar(task: task) } + .toolbar { + Button(action: { + model.shareTask(task) + }, label: { + Image(systemName: "square.and.arrow.up") + }) + } .navigationBarTitleDisplayMode(.inline) .navigationTitle(NSLocalizedString("_task_details_", comment: "")) .onAppear { @@ -28,9 +35,9 @@ struct NCAssistantTaskDetail: View { } #Preview { - let model = NCAssistantTask(controller: nil) + let model = NCAssistantModel(controller: nil) - return NCAssistantTaskDetail(task: NKTextProcessingTask(id: 1, type: "OCP\\TextProcessing\\FreePromptTaskType", status: 1, userId: "christine", appId: "assistant", input: "", output: "", identifier: "", completionExpectedAt: 1712666412)) + return NCAssistantTaskDetail(task: model.selectedTask!) .environmentObject(model) .onAppear { model.loadDummyData() @@ -38,8 +45,8 @@ struct NCAssistantTaskDetail: View { } struct InputOutputScrollView: View { - @EnvironmentObject var model: NCAssistantTask - let task: NKTextProcessingTask + @EnvironmentObject var model: NCAssistantModel + let task: AssistantTask var body: some View { ScrollView { @@ -47,21 +54,22 @@ struct InputOutputScrollView: View { Text(NSLocalizedString("_input_", comment: "")).font(.headline) .padding(.top, 10) - Text(model.selectedTask?.input ?? "") + Text(model.selectedTask?.input?.input ?? "") .frame(maxWidth: .infinity, alignment: .topLeading) .padding() .background(Color(NCBrandColor.shared.textColor2).opacity(0.1)) .clipShape(.rect(cornerRadius: 8)) + .textSelection(.enabled) Text(NSLocalizedString("_output_", comment: "")).font(.headline) .padding(.top, 10) - Text(model.selectedTask?.output ?? "") + Text(model.selectedTask?.output?.output ?? "") .frame(maxWidth: .infinity, alignment: .topLeading) .padding() .background(Color(NCBrandColor.shared.textColor2).opacity(0.1)) .clipShape(.rect(cornerRadius: 8)) - + .textSelection(.enabled) } .padding(.horizontal) .padding(.bottom, 80) @@ -71,32 +79,20 @@ struct InputOutputScrollView: View { } struct BottomDetailsBar: View { - @EnvironmentObject var model: NCAssistantTask - let task: NKTextProcessingTask + @EnvironmentObject var model: NCAssistantModel + let task: AssistantTask var body: some View { VStack(spacing: 0) { Divider() - HStack(alignment: .bottom) { - Label( - title: { - Text(NSLocalizedString(model.selectedTask?.statusInfo.stringKey ?? "", comment: "")) - }, icon: { - Image(systemName: model.selectedTask?.statusInfo.imageSystemName ?? "") - .renderingMode(.original) - .font(Font.system(.body).weight(.light)) - } - ) - .frame(maxWidth: .infinity, alignment: .leading) - if let completionExpectedAt = task.completionExpectedAt { - Text(NCUtility().dateDiff(.init(timeIntervalSince1970: TimeInterval(completionExpectedAt)))) - .frame(maxWidth: .infinity, alignment: .trailing) - } + HStack { + StatusInfo(task: task, showStatusText: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(.bar) + .frame(alignment: .bottom) } - .padding() - .background(.bar) - .frame(alignment: .bottom) } } } diff --git a/iOSClient/Main/Collection Common/Cell/NCListCell.swift b/iOSClient/Main/Collection Common/Cell/NCListCell.swift index c777d513f3..711fefe711 100755 --- a/iOSClient/Main/Collection Common/Cell/NCListCell.swift +++ b/iOSClient/Main/Collection Common/Cell/NCListCell.swift @@ -250,7 +250,7 @@ class NCListCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellProto } func writeInfoDateSize(date: NSDate, size: Int64) { - labelInfo.text = NCUtility().dateDiff(date as Date) + labelInfo.text = NCUtility().getRelativeDateTitle(date as Date) labelSubinfo.text = NCUtilityFileSystem().transformedSize(size) } diff --git a/iOSClient/Main/Create cloud/NCCreateFormUploadConflict.swift b/iOSClient/Main/Create cloud/NCCreateFormUploadConflict.swift index d3a5bbc337..5c84cd5281 100644 --- a/iOSClient/Main/Create cloud/NCCreateFormUploadConflict.swift +++ b/iOSClient/Main/Create cloud/NCCreateFormUploadConflict.swift @@ -342,7 +342,7 @@ extension NCCreateFormUploadConflict: UITableViewDataSource { cell.imageAlreadyExistingFile.image = UIImage(named: metadataAlreadyExists.iconName) } } - cell.labelDetailAlreadyExistingFile.text = utility.dateDiff(metadataAlreadyExists.date as Date) + "\n" + utilityFileSystem.transformedSize(metadataAlreadyExists.size) + cell.labelDetailAlreadyExistingFile.text = utility.getRelativeDateTitle(metadataAlreadyExists.date as Date) + "\n" + utilityFileSystem.transformedSize(metadataAlreadyExists.size) if metadatasConflictAlreadyExistingFiles.contains(metadataNewFile.ocId) { cell.switchAlreadyExistingFile.isOn = true @@ -381,7 +381,7 @@ extension NCCreateFormUploadConflict: UITableViewDataSource { let fileDictionary = try FileManager.default.attributesOfItem(atPath: fileNamePath) let fileSize = fileDictionary[FileAttributeKey.size] as? Int64 ?? 0 - cell.labelDetailNewFile.text = utility.dateDiff(date) + "\n" + utilityFileSystem.transformedSize(fileSize) + cell.labelDetailNewFile.text = utility.getRelativeDateTitle(date) + "\n" + utilityFileSystem.transformedSize(fileSize) } catch { print("Error: \(error)") } @@ -405,7 +405,7 @@ extension NCCreateFormUploadConflict: UITableViewDataSource { DispatchQueue.main.async { cell.imageNewFile.image = image } } } - DispatchQueue.main.async { cell.labelDetailNewFile.text = self.utility.dateDiff(date) + "\n" + self.utilityFileSystem.transformedSize(fileSize) } + DispatchQueue.main.async { cell.labelDetailNewFile.text = self.utility.getRelativeDateTitle(date) + "\n" + self.utilityFileSystem.transformedSize(fileSize) } } catch { print("Error: \(error)") } } } @@ -424,7 +424,7 @@ extension NCCreateFormUploadConflict: UITableViewDataSource { let fileDictionary = try FileManager.default.attributesOfItem(atPath: filePathNewFile) let fileSize = fileDictionary[FileAttributeKey.size] as? Int64 ?? 0 - cell.labelDetailNewFile.text = utility.dateDiff(metadataNewFile.date as Date) + "\n" + utilityFileSystem.transformedSize(fileSize) + cell.labelDetailNewFile.text = utility.getRelativeDateTitle(metadataNewFile.date as Date) + "\n" + utilityFileSystem.transformedSize(fileSize) } catch { print("Error: \(error)") } diff --git a/iOSClient/More/Cells/BaseNCMoreCell.swift b/iOSClient/More/Cells/BaseNCMoreCell.swift index 68887aa501..9b5b705cd1 100644 --- a/iOSClient/More/Cells/BaseNCMoreCell.swift +++ b/iOSClient/More/Cells/BaseNCMoreCell.swift @@ -42,7 +42,7 @@ class BaseNCMoreCell: UITableViewCell { } } - open func setupCell(account: String) {} + open func setupCell(account: String, controller: NCMainTabBarController?) {} override func awakeFromNib() { super.awakeFromNib() diff --git a/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift b/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift index fc54e92f9d..2dac0e0604 100644 --- a/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift +++ b/iOSClient/More/Cells/NCMoreAppSuggestionsCell.swift @@ -49,13 +49,15 @@ class NCMoreAppSuggestionsCell: BaseNCMoreCell { moreAppsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(moreAppsTapped))) } - override func setupCell(account: String) { + override func setupCell(account: String, controller: NCMainTabBarController?) { assistantView.isHidden = !NCCapabilities.shared.getCapabilities(account: account).capabilityAssistantEnabled + self.controller = controller } @objc func assistantTapped() { if let viewController = self.window?.rootViewController { - let assistant = NCAssistant().environmentObject(NCAssistantTask(controller: controller)) + let assistant = NCAssistant() + .environmentObject(NCAssistantModel(controller: self.controller)) let hostingController = UIHostingController(rootView: assistant) viewController.present(hostingController, animated: true, completion: nil) } diff --git a/iOSClient/More/NCMore.swift b/iOSClient/More/NCMore.swift index f0ac00bfff..342f5e56b0 100644 --- a/iOSClient/More/NCMore.swift +++ b/iOSClient/More/NCMore.swift @@ -304,12 +304,12 @@ class NCMore: UIViewController, UITableViewDelegate, UITableViewDataSource { if section.type == .moreApps { guard let cell = tableView.dequeueReusableCell(withIdentifier: NCMoreAppSuggestionsCell.reuseIdentifier, for: indexPath) as? NCMoreAppSuggestionsCell else { return UITableViewCell() } - cell.setupCell(account: session.account) + cell.setupCell(account: session.account, controller: controller) return cell } else { guard let cell = tableView.dequeueReusableCell(withIdentifier: CCCellMore.reuseIdentifier, for: indexPath) as? CCCellMore else { return UITableViewCell() } - cell.setupCell(account: session.account) + cell.setupCell(account: session.account, controller: controller) let item = sections[indexPath.section].items[indexPath.row] @@ -383,7 +383,8 @@ class NCMore: UIViewController, UITableViewDelegate, UITableViewDataSource { alertController.addAction(actionNo) self.present(alertController, animated: true, completion: nil) } else if item.url == "openAssistant" { - let assistant = NCAssistant().environmentObject(NCAssistantTask(controller: self.controller)) + let assistant = NCAssistant() + .environmentObject(NCAssistantModel(controller: self.controller)) let hostingController = UIHostingController(rootView: assistant) present(hostingController, animated: true, completion: nil) } else if item.url == "openSettings" { diff --git a/iOSClient/NCGlobal.swift b/iOSClient/NCGlobal.swift index 791aa25a3f..016291543e 100644 --- a/iOSClient/NCGlobal.swift +++ b/iOSClient/NCGlobal.swift @@ -70,6 +70,7 @@ final class NCGlobal: Sendable { let nextcloudVersion26: Int = 26 let nextcloudVersion27: Int = 27 let nextcloudVersion28: Int = 28 + let nextcloudVersion30: Int = 30 let nextcloudVersion31: Int = 31 let nextcloudVersion99: Int = 99 diff --git a/iOSClient/Notification/NCNotification.swift b/iOSClient/Notification/NCNotification.swift index 64917c6e2d..322a519aa9 100644 --- a/iOSClient/Notification/NCNotification.swift +++ b/iOSClient/Notification/NCNotification.swift @@ -157,7 +157,7 @@ class NCNotification: UITableViewController, NCNotificationCellDelegate { cell.date.text = DateFormatter.localizedString(from: notification.date as Date, dateStyle: .medium, timeStyle: .medium) cell.notification = notification - cell.date.text = utility.dateDiff(notification.date as Date) + cell.date.text = utility.getRelativeDateTitle(notification.date as Date) cell.date.textColor = NCBrandColor.shared.iconImageColor2 cell.subject.text = notification.subject cell.subject.textColor = NCBrandColor.shared.textColor diff --git a/iOSClient/Select/NCSelect.swift b/iOSClient/Select/NCSelect.swift index 42bf12204c..34e3f6b49c 100644 --- a/iOSClient/Select/NCSelect.swift +++ b/iOSClient/Select/NCSelect.swift @@ -361,11 +361,11 @@ extension NCSelect: UICollectionViewDataSource { } cell.imageItem.image = cell.imageItem.image?.colorizeFolder(metadata: metadata) - cell.labelInfo.text = utility.dateDiff(metadata.date as Date) + cell.labelInfo.text = utility.getRelativeDateTitle(metadata.date as Date) } else { - cell.labelInfo.text = utility.dateDiff(metadata.date as Date) + " · " + utilityFileSystem.transformedSize(metadata.size) + cell.labelInfo.text = utility.getRelativeDateTitle(metadata.date as Date) + " · " + utilityFileSystem.transformedSize(metadata.size) // image local if self.database.getTableLocalFile(ocId: metadata.ocId) != nil { diff --git a/iOSClient/Share/NCShareHeader.swift b/iOSClient/Share/NCShareHeader.swift index a6fa46927c..a18f2b6114 100644 --- a/iOSClient/Share/NCShareHeader.swift +++ b/iOSClient/Share/NCShareHeader.swift @@ -57,7 +57,7 @@ class NCShareHeader: UIView { fileName.text = metadata.fileNameView fileName.textColor = NCBrandColor.shared.textColor info.textColor = NCBrandColor.shared.textColor2 - info.text = utilityFileSystem.transformedSize(metadata.size) + ", " + NCUtility().dateDiff(metadata.date as Date) + info.text = utilityFileSystem.transformedSize(metadata.size) + ", " + NCUtility().getRelativeDateTitle(metadata.date as Date) tagListView.addTags(Array(metadata.tags)) diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 04b1ab698c..91380e934e 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -21,6 +21,7 @@ // "_cancel_" = "Cancel"; +"_edit_" = "Edit"; "_tap_to_cancel_" = "Tap to cancel"; "_cancel_request_" = "Do you want to cancel?"; "_upload_file_" = "Upload file"; @@ -658,6 +659,7 @@ "_new_task_" = "New %@ task"; "_no_tasks_" = "No tasks in here"; "_create_task_subtitle_" = "Use the + button to create one"; +"_edit_task_" = "Edit %@ task"; "_no_types_" = "No provider found"; "_no_types_subtitle_" = "AI Providers need to be installed to use the Assistant."; diff --git a/iOSClient/Transfers/NCTransferCell.swift b/iOSClient/Transfers/NCTransferCell.swift index 7df6bcedbc..6244f14a50 100755 --- a/iOSClient/Transfers/NCTransferCell.swift +++ b/iOSClient/Transfers/NCTransferCell.swift @@ -157,7 +157,7 @@ class NCTransferCell: UICollectionViewCell, UIGestureRecognizerDelegate, NCCellP } func writeInfoDateSize(date: NSDate, size: Int64) { - labelInfo.text = NCUtility().dateDiff(date as Date) + " · " + NCUtilityFileSystem().transformedSize(size) + labelInfo.text = NCUtility().getRelativeDateTitle(date as Date) + " · " + NCUtilityFileSystem().transformedSize(size) } func setIconOutlines() { diff --git a/iOSClient/Trash/Cell/NCTrashCellProtocol.swift b/iOSClient/Trash/Cell/NCTrashCellProtocol.swift index a07941c575..8c82abf6db 100644 --- a/iOSClient/Trash/Cell/NCTrashCellProtocol.swift +++ b/iOSClient/Trash/Cell/NCTrashCellProtocol.swift @@ -39,7 +39,7 @@ extension NCTrashCellProtocol where Self: UICollectionViewCell { self.labelTitle.text = tableTrash.trashbinFileName self.labelTitle.textColor = NCBrandColor.shared.textColor if self is NCTrashListCell { - self.labelInfo?.text = NCUtility().dateDiff(tableTrash.trashbinDeletionTime as Date) + self.labelInfo?.text = NCUtility().getRelativeDateTitle(tableTrash.trashbinDeletionTime as Date) } else { let dateFormatter = DateFormatter() dateFormatter.dateStyle = .short diff --git a/iOSClient/Utility/NCUtility+Date.swift b/iOSClient/Utility/NCUtility+Date.swift index 39241ab26a..55fa93f48c 100644 --- a/iOSClient/Utility/NCUtility+Date.swift +++ b/iOSClient/Utility/NCUtility+Date.swift @@ -24,9 +24,12 @@ import UIKit extension NCUtility { - + /// Returns a localized string representing the given date in a user-friendly format. + /// The function handles the following cases: + /// - If the date is today: Returns "Today". + /// - If the date is yesterday: Returns "Yesterday". + /// - Otherwise, it returns the date in a long format (e.g., "10 February 2025"). func getTitleFromDate(_ date: Date) -> String { - guard let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date()) else { return DateFormatter.localizedString(from: date, dateStyle: .long, timeStyle: .none) } @@ -46,8 +49,14 @@ extension NCUtility { } } - func dateDiff(_ date: Date?) -> String { - + /// Represents date as relative time: (e.g., "1 minute ago", "2 hours ago", "3 days ago", or a formatted date). + /// The function handles the following cases: + /// - Less than a minute: Returns "Less than a minute ago". + /// - Less than an hour: Returns the number of minutes (e.g., "5 minutes ago"). + /// - Less than a day: Returns the number of hours (e.g., "2 hours ago"). + /// - Less than a month: Returns the number of days (e.g., "3 days ago"). + /// - More than a month: Returns the full formatted date (e.g., "Jan 10, 2025"). + func getRelativeDateTitle(_ date: Date?) -> String { guard let date else { return "" } let today = Date() var ti = date.timeIntervalSince(today) @@ -78,7 +87,7 @@ extension NCUtility { } else { let formatter = DateFormatter() formatter.formatterBehavior = .behavior10_4 - formatter.dateStyle = .medium + formatter.dateStyle = .medium // Returns formatted date, e.g., "Jan 10, 2025" return formatter.string(from: date) } }