diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index b806786b9c..027fd03f3f 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -276,6 +276,10 @@ 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 */; }; + 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 */; }; @@ -1388,6 +1392,10 @@ B5F545A02DF1C68F008137AB /* Shareable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shareable.swift; sourceTree = ""; }; C0046CDA2A17B98400D87C9D /* NextcloudUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NextcloudUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C04E2F202A17BB4D001BAD85 /* NextcloudIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NextcloudIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 = ""; }; @@ -2306,6 +2314,25 @@ path = NextcloudUITests; sourceTree = ""; }; + C0A0D4822E1BD6EA00476BFF /* Albums */ = { + isa = PBXGroup; + children = ( + C0BFE58B2E1D2EAE00D945DB /* List */, + C0A0D48A2E1BEBC600476BFF /* Albums+WebDAV.swift */, + C0A0D4842E1BD73700476BFF /* AlbumsViewController.swift */, + ); + path = Albums; + sourceTree = ""; + }; + C0BFE58B2E1D2EAE00D945DB /* List */ = { + isa = PBXGroup; + children = ( + C0A0D4882E1BDABD00476BFF /* AlbumsListScreen.swift */, + C0A0D48C2E1CECF200476BFF /* AlbumsListViewModel.swift */, + ); + path = List; + sourceTree = ""; + }; F30A962A2A27A9C800D7BCFE /* Tests */ = { isa = PBXGroup; children = ( @@ -3492,6 +3519,7 @@ F7F67BAA1A24D27800EE80DA /* iOSClient */ = { isa = PBXGroup; children = ( + C0A0D4822E1BD6EA00476BFF /* Albums */, B5E2E73A2DAE89D700AB2EDD /* EmptyView */, 56A32E6F2CE4B75C0020EFF5 /* Analytics */, B52FAE9B2DA8DED9001AB1BD /* NMC Custom Views */, @@ -4635,6 +4663,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 */, @@ -4656,6 +4685,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 */, @@ -4753,6 +4783,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 */, @@ -4954,6 +4985,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..276dc54eb5 --- /dev/null +++ b/iOSClient/Albums/Albums+WebDAV.swift @@ -0,0 +1,239 @@ +// +// 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 + +public struct Album { + let name: String + let lastPhotoId: String? + let itemCount: Int? + let location: String? + let dateRange: String? + let collaborators: String? +} + +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, + lastPhotoId: nil, + itemCount: nil, + location: nil, + dateRange: nil, + collaborators: nil + ) + albums.append(album) + } + } + + return albums + + // return [ + // Album( + // name: "Sample Album", + // lastPhotoId: nil, + // itemCount: nil, + // location: nil, + // dateRange: nil, + // collaborators: nil + // ) + // ] + } + + 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..635e071337 --- /dev/null +++ b/iOSClient/Albums/AlbumsViewController.swift @@ -0,0 +1,37 @@ +// +// AlbumsController.swift +// Nextcloud +// +// Created by A200118228 on 07/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import UIKit +import SwiftUI + +class AlbumsViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + let appDelegate = UIApplication.shared.delegate as! AppDelegate + + let viewModel = AlbumsListViewModel(account: appDelegate.account) + let albumsView = AlbumsListScreen(viewModel: viewModel) + + let hostingController = UIHostingController(rootView: albumsView) + + 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) + } +} diff --git a/iOSClient/Albums/List/AlbumsListScreen.swift b/iOSClient/Albums/List/AlbumsListScreen.swift new file mode 100644 index 0000000000..773b8d8e27 --- /dev/null +++ b/iOSClient/Albums/List/AlbumsListScreen.swift @@ -0,0 +1,176 @@ +// +// AlbumsRootView.swift +// Nextcloud +// +// Created by A200118228 on 07/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI +import SVGKit + +struct AlbumsListScreen: View { + + @State private var isPresentingNewAlbum = false + @State private var newAlbumName: String = "" + + @StateObject private var viewModel: AlbumsListViewModel + + init(viewModel: AlbumsListViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + + NavigationView { + + Group { + if viewModel.isLoading { + ProgressView("Loading albums...") + } else if let error = viewModel.errorMessage { + Text("Error: \(error)") + } else if viewModel.albums.isEmpty { + VStack(spacing: 20) { + + SVGImageView( + url: AssetExtractor.createLocalUrl(forImageNamed: "octopus.svg")!, + size: CGSize(width: 200, height: 200) + ) + .frame(width: 200, height: 200) + Text("No albums yet") + .font(.headline) + .foregroundColor(.gray) + } + } else { + List(viewModel.albums, id: \.name) { album in + Text(album.name) + } + } + } + .navigationTitle("Albums") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + isPresentingNewAlbum = true + }) { + Text("New") + } + } + } + .sheet(isPresented: $isPresentingNewAlbum) { + NewAlbumPopupView( + isPresented: $isPresentingNewAlbum, + albumName: $newAlbumName, + onCreate: { + print("Creating album: \(newAlbumName)") + // TODO: Call your API / ViewModel here + newAlbumName = "" + } + ) + } + } + .onAppear { + viewModel.loadAlbums() + } + } +} + +class AssetExtractor { + + static func createLocalUrl(forImageNamed name: String) -> URL? { + + let fileManager = FileManager.default + let cacheDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let url = cacheDirectory.appendingPathComponent(name) + let path = url.path + + guard fileManager.fileExists(atPath: path) else { + guard + let image = UIImage(named: name), + let data = image.pngData() + else { return nil } + + fileManager.createFile(atPath: path, contents: data, attributes: nil) + return url + } + + return url + } +} + +struct NewAlbumPopupView: View { + + @Binding var isPresented: Bool + @Binding var albumName: String + + var onCreate: () -> Void + + var body: some View { + + NavigationView { + + VStack(alignment: .leading, spacing: 20) { + + Text("Create New Album") + .font(.title2) + .bold() + + Text("Enter a name for your new photo album.") + .font(.subheadline) + .foregroundColor(.gray) + + TextField("Album name", text: $albumName) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding(.top) + + Spacer() + + HStack { + Button("Cancel") { + isPresented = false + albumName = "" + } + .foregroundColor(.red) + + Spacer() + + Button("Create") { + onCreate() + isPresented = false + } + .disabled(albumName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding() + .navigationBarHidden(true) + } + } +} + +struct SVGImageView: UIViewRepresentable { + + var url:URL + var size:CGSize + + func updateUIView(_ uiView: SVGKFastImageView, context: Context) { + uiView.contentMode = .scaleAspectFit + uiView.image.size = size + } + + func makeUIView(context: Context) -> SVGKFastImageView { + let svgImage = SVGKImage(contentsOf: url) + return SVGKFastImageView(svgkImage: svgImage ?? SVGKImage()) + } +} + +#Preview { + SVGImageView( + url: AssetExtractor.createLocalUrl(forImageNamed: "octopus.svg")!, + size: CGSize(width: 100, height: 100) + ) + .frame(width: 100, height: 100) +} + +//#Preview { +// AlbumsRootView(viewModel: .init(account: "1234")) +//} diff --git a/iOSClient/Albums/List/AlbumsListViewModel.swift b/iOSClient/Albums/List/AlbumsListViewModel.swift new file mode 100644 index 0000000000..375eadbd66 --- /dev/null +++ b/iOSClient/Albums/List/AlbumsListViewModel.swift @@ -0,0 +1,45 @@ +// +// 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 + + init(account: String) { + self.account = account + } + + @Published var albums: [Album] = [] + @Published var isLoading: Bool = false + @Published var errorMessage: String? = nil + + private var cancellables = Set() + + func loadAlbums() { + + 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 + } + } + } +} diff --git a/iOSClient/Images.xcassets/octopus.imageset/Contents.json b/iOSClient/Images.xcassets/octopus.imageset/Contents.json new file mode 100644 index 0000000000..d73ee736d1 --- /dev/null +++ b/iOSClient/Images.xcassets/octopus.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "octopus.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOSClient/Images.xcassets/octopus.imageset/octopus.svg b/iOSClient/Images.xcassets/octopus.imageset/octopus.svg new file mode 100644 index 0000000000..7b081b08d6 --- /dev/null +++ b/iOSClient/Images.xcassets/octopus.imageset/octopus.svg @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOSClient/Main/Main.storyboard b/iOSClient/Main/Main.storyboard index 3a33d07d91..dcf1f8b2fe 100644 --- a/iOSClient/Main/Main.storyboard +++ b/iOSClient/Main/Main.storyboard @@ -1,9 +1,9 @@ - + - - + + @@ -36,12 +36,28 @@ + + + + + + + + + + + + + + + + @@ -49,7 +65,7 @@ - + @@ -68,7 +84,7 @@ - + @@ -87,7 +103,7 @@ - + @@ -106,7 +122,7 @@ - + @@ -122,7 +138,7 @@ - + @@ -141,7 +157,7 @@ - + @@ -241,7 +257,7 @@ - +