diff --git a/Brand/NCBrand.swift b/Brand/NCBrand.swift index 5d08ec461c..28bf0537ae 100755 --- a/Brand/NCBrand.swift +++ b/Brand/NCBrand.swift @@ -38,7 +38,7 @@ let userAgent: String = { @objc public var brand: String = "MagentaCLOUD" @objc public var textCopyrightNextcloudiOS: String = "MagentaCLOUD for iOS %@" @objc public var textCopyrightNextcloudServer: String = "MagentaCLOUD Server %@" - @objc public var loginBaseUrl: String = "https://pre1.next.magentacloud.de" //"https://magentacloud.de" + @objc public var loginBaseUrl: String = "https://magentacloud.de" @objc public var pushNotificationServerProxy: String = "https://push-notifications.nextcloud.com" @objc public var linkLoginHost: String = "https://nextcloud.com/install" @@ -70,7 +70,7 @@ let userAgent: String = { // BRAND ONLY // Set use_login_web_personalized to true for prod and false for configurable path - @objc public var use_login_web_personalized: Bool = true // Don't touch me !! + @objc public var use_login_web_personalized: Bool = false // Don't touch me !! @objc public var use_AppConfig: Bool = false // Don't touch me !! @objc public var use_GroupApps: Bool = true // Don't touch me !! diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 9a44e0e960..f116830b97 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -274,6 +274,14 @@ B5F5459E2DF1A883008137AB /* NCMediaCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77444F322281649000D5EB0 /* NCMediaCell.swift */; }; B5F5459F2DF1C43B008137AB /* NCTrashListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78ACD4821903F850088454D /* NCTrashListCell.swift */; }; B5F545A12DF1C68F008137AB /* Shareable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F545A02DF1C68F008137AB /* Shareable.swift */; }; + C010D98B2E324606007B86CD /* NoAlbumsEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C010D98A2E324604007B86CD /* NoAlbumsEmptyView.swift */; }; + C010D98E2E325DAC007B86CD /* AlbumsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C010D98D2E325DA5007B86CD /* AlbumsRootView.swift */; }; + C08DFB462E378BD200731698 /* AlbumsGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C08DFB452E378BC800731698 /* AlbumsGridView.swift */; }; + C08DFB492E378E5F00731698 /* Album.swift in Sources */ = {isa = PBXBuildFile; fileRef = C08DFB482E378E5E00731698 /* Album.swift */; }; + C0A0D4852E1BD73700476BFF /* AlbumsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A0D4842E1BD73700476BFF /* AlbumsViewController.swift */; }; + C0A0D4892E1BDAC300476BFF /* AlbumsListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A0D4882E1BDABD00476BFF /* AlbumsListScreen.swift */; }; + C0A0D48B2E1BEBD400476BFF /* Albums+WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A0D48A2E1BEBC600476BFF /* Albums+WebDAV.swift */; }; + C0A0D48D2E1CECF900476BFF /* AlbumsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A0D48C2E1CECF200476BFF /* AlbumsListViewModel.swift */; }; D575039F27146F93008DC9DC /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A0D1342591FBC5008F8A13 /* String+Extension.swift */; }; D5B6AA7827200C7200D49C24 /* NCActivityTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6AA7727200C7200D49C24 /* NCActivityTableViewCell.swift */; }; F310B1EF2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = F310B1EE2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift */; }; @@ -1387,7 +1395,15 @@ B5F738BB2E17F42600704243 /* ShareRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareRelease.entitlements; sourceTree = ""; }; B5F738BC2E17F42D00704243 /* NextcloudRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NextcloudRelease.entitlements; sourceTree = ""; }; C0046CDA2A17B98400D87C9D /* NextcloudUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NextcloudUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + C010D98A2E324604007B86CD /* NoAlbumsEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoAlbumsEmptyView.swift; sourceTree = ""; }; + C010D98D2E325DA5007B86CD /* AlbumsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumsRootView.swift; sourceTree = ""; }; C04E2F202A17BB4D001BAD85 /* NextcloudIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NextcloudIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + C08DFB452E378BC800731698 /* AlbumsGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumsGridView.swift; sourceTree = ""; }; + C08DFB482E378E5E00731698 /* Album.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Album.swift; sourceTree = ""; }; + C0A0D4842E1BD73700476BFF /* AlbumsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumsViewController.swift; sourceTree = ""; }; + C0A0D4882E1BDABD00476BFF /* AlbumsListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumsListScreen.swift; sourceTree = ""; }; + C0A0D48A2E1BEBC600476BFF /* Albums+WebDAV.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Albums+WebDAV.swift"; sourceTree = ""; }; + C0A0D48C2E1CECF200476BFF /* AlbumsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumsListViewModel.swift; sourceTree = ""; }; D5B6AA7727200C7200D49C24 /* NCActivityTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCActivityTableViewCell.swift; sourceTree = ""; }; 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 = ""; }; @@ -2307,6 +2323,45 @@ path = NextcloudUITests; sourceTree = ""; }; + C010D98C2E325DA0007B86CD /* Root */ = { + isa = PBXGroup; + children = ( + C010D98D2E325DA5007B86CD /* AlbumsRootView.swift */, + ); + path = Root; + sourceTree = ""; + }; + C08DFB472E378E4F00731698 /* Models */ = { + isa = PBXGroup; + children = ( + C08DFB482E378E5E00731698 /* Album.swift */, + ); + path = Models; + sourceTree = ""; + }; + C0A0D4822E1BD6EA00476BFF /* Albums */ = { + isa = PBXGroup; + children = ( + C08DFB472E378E4F00731698 /* Models */, + C010D98C2E325DA0007B86CD /* Root */, + C0BFE58B2E1D2EAE00D945DB /* List */, + C0A0D48A2E1BEBC600476BFF /* Albums+WebDAV.swift */, + C0A0D4842E1BD73700476BFF /* AlbumsViewController.swift */, + ); + path = Albums; + sourceTree = ""; + }; + C0BFE58B2E1D2EAE00D945DB /* List */ = { + isa = PBXGroup; + children = ( + C08DFB452E378BC800731698 /* AlbumsGridView.swift */, + C010D98A2E324604007B86CD /* NoAlbumsEmptyView.swift */, + C0A0D4882E1BDABD00476BFF /* AlbumsListScreen.swift */, + C0A0D48C2E1CECF200476BFF /* AlbumsListViewModel.swift */, + ); + path = List; + sourceTree = ""; + }; F30A962A2A27A9C800D7BCFE /* Tests */ = { isa = PBXGroup; children = ( @@ -3494,6 +3549,7 @@ F7F67BAA1A24D27800EE80DA /* iOSClient */ = { isa = PBXGroup; children = ( + C0A0D4822E1BD6EA00476BFF /* Albums */, B5E2E73A2DAE89D700AB2EDD /* EmptyView */, 56A32E6F2CE4B75C0020EFF5 /* Analytics */, B52FAE9B2DA8DED9001AB1BD /* NMC Custom Views */, @@ -4636,6 +4692,7 @@ B5E2E7322DAE894C00AB2EDD /* NCSectionHeaderMenu.swift in Sources */, F78C6FDE296D677300C952C3 /* NCContextMenu.swift in Sources */, B5F079F42DF8557500EBD527 /* FileNameInputTextField.swift in Sources */, + C0A0D4892E1BDAC300476BFF /* AlbumsListScreen.swift in Sources */, F7E402332BA89551007E5609 /* NCTrash+Networking.swift in Sources */, B5F545962DF19556008137AB /* PasswordInputField.swift in Sources */, F7E7AEA72BA32D0000512E52 /* NCCollectionViewUnifiedSearch.swift in Sources */, @@ -4657,6 +4714,7 @@ F77DD6A82C5CC093009448FB /* NCSession.swift in Sources */, F702F30825EE5D47008F8E80 /* NCPopupViewController.swift in Sources */, B5E2E7282DAE880700AB2EDD /* NCCreateFormUploadVoiceNote.swift in Sources */, + C0A0D48D2E1CECF900476BFF /* AlbumsListViewModel.swift in Sources */, B5F545972DF195D9008137AB /* AnalyticsHelper.swift in Sources */, 370D26AF248A3D7A00121797 /* NCCellProtocol.swift in Sources */, F32FADA92D1176E3007035E2 /* UIButton+Extension.swift in Sources */, @@ -4718,6 +4776,7 @@ F70753F12542A9A200972D44 /* NCViewerMedia.swift in Sources */, F799DF822C4B7DCC003410B5 /* NCSectionFooter.swift in Sources */, F76B649C2ADFFAED00014640 /* NCImageCache.swift in Sources */, + C08DFB492E378E5F00731698 /* Album.swift in Sources */, F78A18B823CDE2B300F681F3 /* NCViewerRichWorkspace.swift in Sources */, F768822E2C0DD1E7001CF441 /* NCSettingsBundleHelper.swift in Sources */, F7A60F86292D215000FCE1F2 /* NCShareAccounts.swift in Sources */, @@ -4753,6 +4812,7 @@ F7A846DE2BB01ACB0024816F /* NCTrashCellProtocol.swift in Sources */, F799DF852C4B7E56003410B5 /* NCSectionHeader.swift in Sources */, F78A10BF29322E8A008499B8 /* NCManageDatabase+Directory.swift in Sources */, + C0A0D4852E1BD73700476BFF /* AlbumsViewController.swift in Sources */, F7743A122C33F0A20034F670 /* NCCollectionViewCommon+CollectionViewDelegate.swift in Sources */, F7D60CAF2C941ACB008FBFDD /* NCMediaPinchGesture.swift in Sources */, F704B5E92430C0B800632F5F /* NCCreateFormUploadConflictCell.swift in Sources */, @@ -4762,6 +4822,7 @@ F700510522DF6A89003A3356 /* NCShare.swift in Sources */, F785EE9D246196DF00B3F945 /* NCNetworkingE2EE.swift in Sources */, F724377B2C10B83E00C7C68D /* NCPermissions.swift in Sources */, + C010D98E2E325DAC007B86CD /* AlbumsRootView.swift in Sources */, F794E13D2BBBFF2E003693D7 /* NCMainTabBarController.swift in Sources */, F7D4BF3D2CA2E8D800A5E746 /* TOPasscodeKeypadView.m in Sources */, F7D4BF3E2CA2E8D800A5E746 /* TOPasscodeSettingsKeypadView.m in Sources */, @@ -4803,6 +4864,7 @@ F7A0D1352591FBC5008F8A13 /* String+Extension.swift in Sources */, F7F9D1BB25397CE000D9BFF5 /* NCViewer.swift in Sources */, F7E7AEA52BA32C6500512E52 /* NCCollectionViewDownloadThumbnail.swift in Sources */, + C08DFB462E378BD200731698 /* AlbumsGridView.swift in Sources */, B5E2E70D2DAE86DC00AB2EDD /* HelpViewController.swift in Sources */, B5E2E70E2DAE86DC00AB2EDD /* MagentaCloudVersionView.swift in Sources */, B5E2E7102DAE86DC00AB2EDD /* CCAdvanced.m in Sources */, @@ -4900,6 +4962,7 @@ AF93474C27E34120002537EE /* NCUtility+Image.swift in Sources */, F7AEEAA62C11DBAF00011412 /* NCAccountSettingsView.swift in Sources */, F702F30125EE5D2C008F8E80 /* NYMnemonic.m in Sources */, + C010D98B2E324606007B86CD /* NoAlbumsEmptyView.swift in Sources */, AF93474E27E3F212002537EE /* NCShareNewUserAddComment.swift in Sources */, F7C30DFD291BD0B80017149B /* NCNetworkingE2EEDelete.swift in Sources */, F76882302C0DD1E7001CF441 /* NCFileNameModel.swift in Sources */, @@ -4954,6 +5017,7 @@ F77C97392953131000FDDD09 /* NCCameraRoll.swift in Sources */, F343A4B32A1E01FF00DDA874 /* PHAsset+Extension.swift in Sources */, F70968A424212C4E00ED60E5 /* NCLivePhoto.swift in Sources */, + C0A0D48B2E1BEBD400476BFF /* Albums+WebDAV.swift in Sources */, F7C30DFA291BCF790017149B /* NCNetworkingE2EECreateFolder.swift in Sources */, F722133B2D40EF9D002F7438 /* NCFilesNavigationController.swift in Sources */, F7BC288026663F85004D46C5 /* NCViewCertificateDetails.swift in Sources */, diff --git a/iOSClient/Albums/Albums+WebDAV.swift b/iOSClient/Albums/Albums+WebDAV.swift new file mode 100644 index 0000000000..480ea19cbb --- /dev/null +++ b/iOSClient/Albums/Albums+WebDAV.swift @@ -0,0 +1,228 @@ +// +// Albums+WebDAV.swift +// Nextcloud +// +// Created by A200118228 on 07/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import NextcloudKit +import Alamofire +import SwiftyJSON +import SwiftyXMLParser + +fileprivate extension String { + var decodedAlbumName: String { + guard let lastComponent = self.split(separator: "/").last else { + return self + } + return lastComponent.removingPercentEncoding ?? String(lastComponent) + } +} + +public extension NextcloudKit { + + func fetchAllAlbums( + for account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result<[Album], Error>) -> Void + ) { + + let session = NCSession.shared.getSession(account: account) + + //options.contentType = "application/xml" + + let urlPath = session.urlBase + "/remote.php/dav/photos/" + session.user + "/albums/" + + guard let nkSession = nkCommonInstance.getSession(account: account), + let url = urlPath.encodedToUrl, + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { completion(.failure(NKError.urlError)) } + } + + let method = HTTPMethod(rawValue: "PROPFIND") + + let propfindXML = """ + + + + + + + + + + + """ + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: method, headers: headers) + urlRequest.httpBody = propfindXML.data(using: .utf8) + urlRequest.timeoutInterval = options.timeout + } catch { + return options.queue.async { completion(.failure(NKError(error: error))) } + } + + nkSession.sessionData.request( + urlRequest, + //interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance) + ) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .response(queue: self.nkCommonInstance.backgroundQueue) { response in + + if self.nkCommonInstance.levelLog > 0 { + debugPrint(response) + } + + let statusCode = response.response?.statusCode + + // Explicit 404 check + if statusCode == 404 { + let error = NKError.success + return options.queue.async { + completion(.success([])) + } + } + + switch response.result { + + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { completion(.failure(error)) } + + case .success: + + guard let data = response.data else { + return options.queue.async { + completion(.failure(NKError.invalidData)) + } + } + + let albums = self.parseAlbumsXML(data: data) + options.queue.async { + completion(.success(albums)) + } + } + } + } + + private func parseAlbumsXML(data: Data) -> [Album] { + + let xml = XML.parse(data) + var albums: [Album] = [] + + let elements = xml["d:multistatus", "d:response"] + + for element in elements { + + let href = element["d:href"].element?.text ?? "" + + let prop = element["d:propstat"]["d:prop"] + + let lastPhoto = prop["nc:last-photo"].element?.text + let nbItems = prop["nc:nbItems"].element?.text.flatMap { Int($0) } + let location = prop["nc:location"].element?.text + let dateRange = prop["nc:dateRange"].element?.text + let collaborators = prop["nc:collaborators"].element?.text + + // Optionally skip entries with 404 status + let status = element["d:propstat"]["d:status"].element?.text ?? "" + if status.contains("200") { + let album = Album( + name: href.decodedAlbumName, + lastPhotoId: lastPhoto, + itemCount: nbItems, + location: location, + dateRange: dateRange, + collaborators: collaborators + ) + albums.append(album) + } + } + + return albums + } + + func createNewAlbum( + for account: String, + albumName: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result) -> Void + ) { + + let session = NCSession.shared.getSession(account: account) + + //options.contentType = "application/xml" + + let urlPath = session.urlBase + "/remote.php/dav/photos/" + session.user + "/albums/\(albumName)/" + + guard let nkSession = nkCommonInstance.getSession(account: account), + let url = urlPath.encodedToUrl, + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { completion(.failure(NKError.urlError)) } + } + + let method = HTTPMethod(rawValue: "MKCOL") + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: method, headers: headers) + urlRequest.timeoutInterval = options.timeout + } catch { + return options.queue.async { completion(.failure(NKError(error: error))) } + } + + nkSession.sessionData.request( + urlRequest, + //interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance) + ) + // .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .response(queue: self.nkCommonInstance.backgroundQueue) { response in + + if self.nkCommonInstance.levelLog > 0 { + debugPrint(response) + } + + let statusCode = response.response?.statusCode + + // Explicit 405 check + if statusCode == 405 { + let error = NKError(errorCode: 405, errorDescription: "Album already exists!", responseData: nil) + return options.queue.async { + completion(.failure(error)) + } + } + + switch response.result { + + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { completion(.failure(error)) } + + case .success: + + guard let data = response.data else { + return options.queue.async { + completion(.failure(NKError.invalidData)) + } + } + + let albums = self.parseAlbumsXML(data: data) + options.queue.async { + completion(.success(true)) + } + } + } + } +} diff --git a/iOSClient/Albums/AlbumsViewController.swift b/iOSClient/Albums/AlbumsViewController.swift new file mode 100644 index 0000000000..efa5f1daac --- /dev/null +++ b/iOSClient/Albums/AlbumsViewController.swift @@ -0,0 +1,52 @@ +// +// AlbumsController.swift +// Nextcloud +// +// Created by A200118228 on 07/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import UIKit +import SwiftUI + +class AlbumsViewController: UIViewController { + + @Environment(\.localAccount) var localAccount: String + + override func viewDidLoad() { + super.viewDidLoad() + + let appDelegate = UIApplication.shared.delegate as! AppDelegate + + let albumsRootView = AlbumsRootView() + .environment(\.localAccount, appDelegate.account) + + let hostingController = UIHostingController(rootView: albumsRootView) + + addChild(hostingController) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingController.view) + + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + hostingController.didMove(toParent: self) + + UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = NCBrandColor.shared.customer + } +} + +struct AccountKey: EnvironmentKey { + static let defaultValue: String = "" +} + +extension EnvironmentValues { + var localAccount: String { + get { self[AccountKey.self] } + set { self[AccountKey.self] = newValue } + } +} diff --git a/iOSClient/Albums/List/AlbumsGridView.swift b/iOSClient/Albums/List/AlbumsGridView.swift new file mode 100644 index 0000000000..b145c02351 --- /dev/null +++ b/iOSClient/Albums/List/AlbumsGridView.swift @@ -0,0 +1,115 @@ +// +// AlbumsGridView.swift +// Nextcloud +// +// Created by Dhanesh on 28/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI + +struct AlbumsGridView: View { + + let albums: [Album] + + private let columns = [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16) + ] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + + Text("My albums") + .font(.system(size: 21, weight: .bold)) + .padding(.horizontal) + + LazyVGrid(columns: columns, spacing: 20) { + + ForEach(albums, id: \.id) { album in + + VStack(alignment: .leading, spacing: 8) { + + if album.lastPhotoId == "-1" || album.itemCount == 0 { + Image("emptyAlbum") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 182, height: 140) // make flexible if needed + .clipped() + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.5), lineWidth: 1) + ) + } else { + Image(album.lastPhotoId ?? "") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 182, height: 140) // make flexible if needed + .clipped() + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.5), lineWidth: 1) + ) + } + + // if let image = NCUtility.createFilePreviewImage( + // ocId: album.lastPhotoId, + // etag: metadata.etag, + // fileNameView: metadata.fileNameView, + // classFile: metadata.classFile, + // status: metadata.status, + // createPreviewMedia: true + // ) { + // + // + // + // } + + Text(album.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.primary) + .lineLimit(1) + + let subtitle: String = { + var parts: [String] = [] + + if let count = album.itemCount { + parts.append("\(count) Objects") + } + + if let date = album.dateRange { + parts.append(date) + } + + return parts.joined(separator: " - ") + }() + + if !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 13)) + .foregroundColor(Color(UIColor.systemGray)) + } + } + } + } + .padding(.horizontal) + } + .padding(.top) + } + } +} + +#if DEBUG +#Preview { + AlbumsGridView( + albums: [ + Album(name: "Geburtstagsalbum", lastPhotoId: "birthday", itemCount: 16, location: "Berlin", dateRange: "Feb 2022", collaborators: "Anna, John"), + Album(name: "Urlaub", lastPhotoId: "mountain", itemCount: 42, location: "Alps", dateRange: nil, collaborators: nil), + Album(name: "Office Party", lastPhotoId: "-1", itemCount: 0, location: nil, dateRange: "Dec 2023", collaborators: nil) + ] + ) +} +#endif diff --git a/iOSClient/Albums/List/AlbumsListScreen.swift b/iOSClient/Albums/List/AlbumsListScreen.swift new file mode 100644 index 0000000000..229e636b2c --- /dev/null +++ b/iOSClient/Albums/List/AlbumsListScreen.swift @@ -0,0 +1,81 @@ +// +// AlbumsRootView.swift +// Nextcloud +// +// Created by A200118228 on 07/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI +import SVGKit + +struct AlbumsListScreen: View { + + @StateObject private var viewModel: AlbumsListViewModel + + init(viewModel: AlbumsListViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + + Group { + if viewModel.isLoading { + ProgressView("Loading albums...") + } else if let error = viewModel.errorMessage { + Text(error) + } else if viewModel.albums.isEmpty { + NoAlbumsEmptyView(onNewAlbumCreationIntent: { + viewModel.onNewAlbumClick() + }) + } else { + AlbumsGridView(albums: viewModel.albums) + } + } + .navigationTitle("Albums") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("New") { + viewModel.onNewAlbumClick() + } + .foregroundColor(Color(NCBrandColor.shared.customer)) + } + } + .alert( + "Create new Album", + isPresented: $viewModel.isNewAlbumCreationPopupVisible + ) { + + TextField("Album's name", text: $viewModel.newAlbumName) + + Button("Cancel", role: .cancel) { + viewModel.onNewAlbumPopupCancel() + } + + Button("Create") { + viewModel.onNewAlbumPopupCreate() + } + .disabled(viewModel.newAlbumNameError != nil) + } message: { + Text("Please enter an album name between 3 and 30 characters.") + .foregroundColor(.secondary) + } + .onAppear { + viewModel.loadAlbums() + } + } +} + +#if DEBUG +#Preview { + NavigationView { + AlbumsListScreen(viewModel: .init(account: "123")) + }.onAppear { + UIView + .appearance( + whenContainedInInstancesOf: [UIAlertController.self] + ).tintColor = NCBrandColor.shared.customer + } +} +#endif diff --git a/iOSClient/Albums/List/AlbumsListViewModel.swift b/iOSClient/Albums/List/AlbumsListViewModel.swift new file mode 100644 index 0000000000..a42278f4bb --- /dev/null +++ b/iOSClient/Albums/List/AlbumsListViewModel.swift @@ -0,0 +1,110 @@ +// +// AlbumsViewModel.swift +// Nextcloud +// +// Created by A200118228 on 08/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import Foundation +import Combine +import NextcloudKit +import SVGKit + +class AlbumsListViewModel: ObservableObject { + + private var account: String + + @Published private(set) var albums: [Album] = [] + @Published private(set) var isLoading: Bool = false + @Published private(set) var errorMessage: String? = nil + + @Published var isNewAlbumCreationPopupVisible: Bool = false + @Published var newAlbumName: String = "" + @Published private(set) var newAlbumNameError: String? = nil + + private var cancellables: Set = [] + + init(account: String) { + self.account = account + registerPublishers() + } + + // MARK: - Album name validation + private func registerPublishers() { + $newAlbumName + .removeDuplicates() + .sink { [weak self] name in + guard let self = self else { return } + self.newAlbumNameError = self.validateAlbumName(name).first + } + .store(in: &cancellables) + } + + private func validateAlbumName(_ name: String) -> [String] { + + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.isEmpty { + return ["Album name cannot be empty."] + } else if trimmed.count < 3 { + return ["Album name must be at least 3 characters."] + } else if trimmed.count > 30 { + return ["Album name cannot be more than 30 characters."] + } else if trimmed.contains("/") || trimmed.contains("\\") { + return ["Album name cannot contain slashes."] + } + + return [] + } + + // MARK: - Album name popup + func onNewAlbumClick() { + isNewAlbumCreationPopupVisible = true + } + + func onNewAlbumPopupCancel() { + newAlbumName = "" + isNewAlbumCreationPopupVisible = false + } + + func onNewAlbumPopupCreate() { + + // let errors = validateAlbumName(newAlbumName) + // guard errors.isEmpty else { + // newAlbumNameError = errors.first + // return + // } // TODO: For more defensive coding + + + isNewAlbumCreationPopupVisible = false + createNewAlbum(for: newAlbumName) + newAlbumName = "" + } + + // MARK: - APIs + func loadAlbums() { + + guard !isLoading else { return } // Prevent double calls + + isLoading = true + errorMessage = nil + + NextcloudKit.shared.fetchAllAlbums(for: account) { result in + + self.isLoading = false + + switch result { + case .success(let albums): + self.albums = albums + case .failure(let error): + // self.errorMessage = error.localizedDescription + self.errorMessage = "Unable to load albums. Please try again later!" + } + } + } + + private func createNewAlbum(for name: String) { + + } +} diff --git a/iOSClient/Albums/List/NoAlbumsEmptyView.swift b/iOSClient/Albums/List/NoAlbumsEmptyView.swift new file mode 100644 index 0000000000..0a7457c5cd --- /dev/null +++ b/iOSClient/Albums/List/NoAlbumsEmptyView.swift @@ -0,0 +1,56 @@ +// +// NoAlbumsEmptyView.swift +// Nextcloud +// +// Created by Dhanesh on 24/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI + +struct NoAlbumsEmptyView: View { + + var onNewAlbumCreationIntent: () -> Void + + private let contentPadding: CGFloat = 64.0 + + var body: some View { + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + + Image("noAlbum") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .accessibility(hidden: true) + + Text("Create\nAlbums\nfor your\nPhotos") + .font(.system(size: 58, weight: .bold)) + .padding(.horizontal, contentPadding) + + Text("You can organize all your photos in as many albums as you like. You haven't created an album yet.") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.secondary) + .padding(.horizontal, contentPadding) + + Button(action: onNewAlbumCreationIntent) { + Label("Create album", systemImage: "plus") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(Color(NCBrandColor.shared.customer)) + } + .padding(.horizontal, contentPadding) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +#if DEBUG +#Preview { + NoAlbumsEmptyView(onNewAlbumCreationIntent: {}) +} +#endif diff --git a/iOSClient/Albums/Models/Album.swift b/iOSClient/Albums/Models/Album.swift new file mode 100644 index 0000000000..c61585d6d0 --- /dev/null +++ b/iOSClient/Albums/Models/Album.swift @@ -0,0 +1,17 @@ +// +// Album.swift +// Nextcloud +// +// Created by Dhanesh on 28/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +public struct Album: Identifiable { + public let id = UUID() + let name: String + let lastPhotoId: String? + let itemCount: Int? + let location: String? + let dateRange: String? + let collaborators: String? +} diff --git a/iOSClient/Albums/Root/AlbumsRootView.swift b/iOSClient/Albums/Root/AlbumsRootView.swift new file mode 100644 index 0000000000..1278aa6289 --- /dev/null +++ b/iOSClient/Albums/Root/AlbumsRootView.swift @@ -0,0 +1,23 @@ +// +// AlbumsRootView.swift +// Nextcloud +// +// Created by Dhanesh on 24/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI + +struct AlbumsRootView: View { + + @Environment(\.localAccount) var localAccount: String + + var body: some View { + + NavigationView { + AlbumsListScreen( + viewModel: .init(account: localAccount) + ) + } + } +} diff --git a/iOSClient/Images.xcassets/EmptyAlbum.imageset/Contents.json b/iOSClient/Images.xcassets/EmptyAlbum.imageset/Contents.json new file mode 100644 index 0000000000..7923a3b9d1 --- /dev/null +++ b/iOSClient/Images.xcassets/EmptyAlbum.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "EmptyAlbum.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "EmptyAlbum2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "EmptyAlbum3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOSClient/Images.xcassets/EmptyAlbum.imageset/EmptyAlbum.png b/iOSClient/Images.xcassets/EmptyAlbum.imageset/EmptyAlbum.png new file mode 100644 index 0000000000..cc3aa93f1d Binary files /dev/null and b/iOSClient/Images.xcassets/EmptyAlbum.imageset/EmptyAlbum.png differ diff --git a/iOSClient/Images.xcassets/EmptyAlbum.imageset/EmptyAlbum2x.png b/iOSClient/Images.xcassets/EmptyAlbum.imageset/EmptyAlbum2x.png new file mode 100644 index 0000000000..a7d4c2f4f6 Binary files /dev/null and b/iOSClient/Images.xcassets/EmptyAlbum.imageset/EmptyAlbum2x.png differ diff --git a/iOSClient/Images.xcassets/EmptyAlbum.imageset/EmptyAlbum3x.png b/iOSClient/Images.xcassets/EmptyAlbum.imageset/EmptyAlbum3x.png new file mode 100644 index 0000000000..6a30bf0865 Binary files /dev/null and b/iOSClient/Images.xcassets/EmptyAlbum.imageset/EmptyAlbum3x.png differ diff --git a/iOSClient/Images.xcassets/noAlbum.imageset/Contents.json b/iOSClient/Images.xcassets/noAlbum.imageset/Contents.json new file mode 100644 index 0000000000..96cf4480d1 --- /dev/null +++ b/iOSClient/Images.xcassets/noAlbum.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bg-image-albums.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOSClient/Images.xcassets/noAlbum.imageset/bg-image-albums.png b/iOSClient/Images.xcassets/noAlbum.imageset/bg-image-albums.png new file mode 100644 index 0000000000..a387dc95fe Binary files /dev/null and b/iOSClient/Images.xcassets/noAlbum.imageset/bg-image-albums.png differ diff --git a/iOSClient/Main/Main.storyboard b/iOSClient/Main/Main.storyboard index b92a9a0651..c3932eb4c0 100644 --- a/iOSClient/Main/Main.storyboard +++ b/iOSClient/Main/Main.storyboard @@ -1,9 +1,11 @@ - + - + + + @@ -15,7 +17,7 @@ - + @@ -49,7 +51,7 @@ - + @@ -68,7 +70,7 @@ - + @@ -87,7 +89,7 @@ - + @@ -102,14 +104,19 @@ - - + + - + + + + + + @@ -122,7 +129,7 @@ - + @@ -233,6 +240,22 @@ + + + + + + + + + + + + + + + + @@ -264,4 +287,9 @@ + + + + + diff --git a/iOSClient/Main/NCMainTabBar.swift b/iOSClient/Main/NCMainTabBar.swift index 674e08dcee..ef06666e4d 100644 --- a/iOSClient/Main/NCMainTabBar.swift +++ b/iOSClient/Main/NCMainTabBar.swift @@ -154,7 +154,7 @@ class NCMainTabBar: UITabBar { if let item = items?[3] { item.title = NSLocalizedString("_albums_", comment: "") item.image = UIImage(named: "mediaSelected")?.image(color: NCBrandColor.shared.brandElement, size: 25) - item.isEnabled = false + item.isEnabled = true item.tag = 103 }