From 4b9530ad2f8bd8fa71354b1e7cfeec93627cc392 Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Tue, 14 Jul 2026 10:28:38 +0200 Subject: [PATCH 1/6] feat: redesign notes list in SwiftUI Replace the legacy UIKit notes list and split view with a SwiftUI list that supports searching, pull-to-refresh, drag-and-drop note creation, category grouping, and recent-first sorting. Keep the existing UIKit editor, category picker, sharing flow, and direct-editing integration behind a presentation bridge. Adopt the scene lifecycle required by current iOS releases and remove the obsolete list-related storyboard and controller code. Assisted-by: Codex:GPT-5 Assisted-by: Claude:opus-4.8 Signed-off-by: Julius Knorr --- .swiftlint.yml | 1 - Source/AppDelegate.swift | 46 +- Source/Base.lproj/Main_iPhone.storyboard | 219 +---- Source/CategoriesSceneDelegate.swift | 56 -- Source/CollapsibleTableViewHeaderView.swift | 65 -- Source/CollapsibleTableViewHeaderView.xib | 67 -- Source/EditorViewController.swift | 3 - Source/NotesTableViewCell.swift | 27 - Source/PBHPreviewController.swift | 49 - Source/PBHSplitViewController.swift | 103 -- Source/Screens/Notes/NoteTableViewCell.swift | 13 - .../Notes/NotesTableViewController.swift | 902 ------------------ Utils/KeychainHelper.swift | 12 + iOCNotes.xcodeproj/project.pbxproj | 24 +- iOCNotes/Views/NoteRowView.swift | 66 ++ iOCNotes/Views/NotesListModel.swift | 202 ++++ iOCNotes/Views/NotesListView.swift | 164 ++++ iOCNotes/Views/NotesPresenter.swift | 114 +++ iOCNotes/Views/NotesView.swift | 45 +- 19 files changed, 623 insertions(+), 1555 deletions(-) delete mode 100644 Source/CategoriesSceneDelegate.swift delete mode 100644 Source/CollapsibleTableViewHeaderView.swift delete mode 100644 Source/CollapsibleTableViewHeaderView.xib delete mode 100644 Source/NotesTableViewCell.swift delete mode 100644 Source/PBHPreviewController.swift delete mode 100644 Source/PBHSplitViewController.swift delete mode 100644 Source/Screens/Notes/NoteTableViewCell.swift delete mode 100644 Source/Screens/Notes/NotesTableViewController.swift create mode 100644 iOCNotes/Views/NoteRowView.swift create mode 100644 iOCNotes/Views/NotesListModel.swift create mode 100644 iOCNotes/Views/NotesListView.swift create mode 100644 iOCNotes/Views/NotesPresenter.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 785534f6..8c46d042 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -31,7 +31,6 @@ strict: true file_header: required_pattern: | - \/\/ SPDX-FileCopyrightText: Nextcloud GmbH \/\/ SPDX-FileCopyrightText: 2025 .*? \/\/ SPDX-License-Identifier: GPL-3.0-or-later severity: error diff --git a/Source/AppDelegate.swift b/Source/AppDelegate.swift index 43d8467f..46f18b8f 100644 --- a/Source/AppDelegate.swift +++ b/Source/AppDelegate.swift @@ -15,8 +15,6 @@ import SwiftUI class AppDelegate: UIResponder, UIApplicationDelegate { var store = Store.shared - var notesTableViewController: NotesTableViewController? - /// /// Updated by being the `NextcloudKitDelegate`. /// @@ -27,8 +25,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } private let operationQueue = OperationQueue() - private var updateFrcDelegateNeeded = true - + private var pendingNoteToOpen: Note? + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { NextcloudKit.shared.setup(delegate: self) @@ -71,7 +69,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UITableViewCell.appearance().backgroundColor = .ph_cellBackgroundColor let scrollViewArray = [ - NotesTableViewController.self, CategoryTableViewController.self, EditorViewController.self, PreviewViewController.self, @@ -95,8 +92,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { /// Scene lifecycle is adopted via ``SceneDelegate``; these handlers hold the shared behaviour it forwards. /// func handleDidEnterBackground() { - notesTableViewController?.disableFetchedResultsController() - updateFrcDelegateNeeded = true scheduleAppSync() } @@ -112,19 +107,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func handleDidBecomeActive() { store.synchronize() - updateFrcDelegateIfNeeded() - } - - private func updateFrcDelegateIfNeeded() { - guard updateFrcDelegateNeeded else { - return + DispatchQueue.main.async { + self.openPendingNoteIfNeeded() } - - updateFrcDelegateNeeded = false - notesTableViewController?.configureFetchedResultsController(performFetch: KeychainHelper.didSyncInBackground) - KeychainHelper.didSyncInBackground = false } - + func scheduleAppSync() { BGTaskScheduler.shared.cancelAllTaskRequests() let request = BGAppRefreshTaskRequest(identifier: "com.peterandlinda.iOCNotes.Sync") @@ -167,9 +154,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if let queryItems = urlComponents?.queryItems, let item = queryItems.first(where: { $0.name == "note" }), let content = item.value { - // Make sure we connect the delegate up, as this is called before the app is active - updateFrcDelegateIfNeeded() - self.notesTableViewController?.addNote(content: content) + NoteSessionManager.shared.add(content: content, category: "") { note in + guard let note = note else { return } + DispatchQueue.main.async { + self.openEditorWhenReady(for: note) + } + } } } else if url.isFileURL { do { @@ -184,6 +174,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } return true } + + private func openEditorWhenReady(for note: Note) { + guard NotesPresenter.openEditor(for: note, isNewNote: true) else { + pendingNoteToOpen = note + return + } + + pendingNoteToOpen = nil + } + + private func openPendingNoteIfNeeded() { + guard let pendingNoteToOpen else { return } + openEditorWhenReady(for: pendingNoteToOpen) + } } // MARK: - NextcloudKitDelegate diff --git a/Source/Base.lproj/Main_iPhone.storyboard b/Source/Base.lproj/Main_iPhone.storyboard index eb405e9d..2465fd93 100644 --- a/Source/Base.lproj/Main_iPhone.storyboard +++ b/Source/Base.lproj/Main_iPhone.storyboard @@ -1,5 +1,5 @@ - + @@ -8,220 +8,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -243,9 +29,6 @@ - - - diff --git a/Source/CategoriesSceneDelegate.swift b/Source/CategoriesSceneDelegate.swift deleted file mode 100644 index d3cda95b..00000000 --- a/Source/CategoriesSceneDelegate.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// CategoriesSceneDelegate.swift -// iOCNotes -// -// Created by Peter Hedlund on 12/22/19. -// Copyright © 2019 Peter Hedlund. All rights reserved. -// - -import UIKit -import SwiftUI - -class CategoriesSceneDelegate: UIResponder, UIWindowSceneDelegate { - - var window: UIWindow? - - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard let windowScene = (scene as? UIWindowScene) else { - return - } - - windowScene.title = "Categories" - } - - func sceneDidDisconnect(_ scene: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). - } - - func sceneDidBecomeActive(_ scene: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - } - - func sceneWillResignActive(_ scene: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } - - func sceneWillEnterForeground(_ scene: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. - } - - func sceneDidEnterBackground(_ scene: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. - - } - -} diff --git a/Source/CollapsibleTableViewHeaderView.swift b/Source/CollapsibleTableViewHeaderView.swift deleted file mode 100644 index 58e55a54..00000000 --- a/Source/CollapsibleTableViewHeaderView.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// CollapsibleTableViewHeaderView.swift -// iOCNotes -// -// Created by Peter Hedlund on 8/5/19. -// Copyright © 2019 Peter Hedlund. All rights reserved. -// - -import UIKit - -protocol CollapsibleTableViewHeaderViewDelegate { - func toggleSection(_ header: CollapsibleTableViewHeaderView, sectionTitle: String, sectionIndex: Int) -} - -class CollapsibleTableViewHeaderView: UITableViewHeaderFooterView { - - @IBOutlet var folderImageView: UIImageView! - @IBOutlet var titleLabel: UILabel! - @IBOutlet var collapsedImageView: UIImageView! - - var delegate: CollapsibleTableViewHeaderViewDelegate? - var sectionTitle = Constants.noCategory - var sectionIndex = 0 - - var collapsed: Bool { - didSet { - collapsedImageView.rotate(collapsed ? -(.pi / 2) : 0.0) - } - } - - required init?(coder aDecoder: NSCoder) { - collapsed = false - super.init(coder: aDecoder) - let backgroundView = UIView() - backgroundView.backgroundColor = .ph_backgroundColor - self.backgroundView = backgroundView - addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(CollapsibleTableViewHeaderView.onTap(_:)))) - } - - override func draw(_ rect: CGRect) { - super.draw(rect) - } - - @objc func onTap(_ gestureRecognizer: UITapGestureRecognizer) { - guard let _ = gestureRecognizer.view as? CollapsibleTableViewHeaderView else { - return - } - delegate?.toggleSection(self, sectionTitle: sectionTitle, sectionIndex: sectionIndex) - } -} - -extension UIView { - - func rotate(_ toValue: CGFloat, duration: CFTimeInterval = 0.2) { - let animation = CABasicAnimation(keyPath: "transform.rotation") - - animation.toValue = toValue - animation.duration = duration - animation.isRemovedOnCompletion = false - animation.fillMode = CAMediaTimingFillMode.forwards - - self.layer.add(animation, forKey: nil) - } - -} diff --git a/Source/CollapsibleTableViewHeaderView.xib b/Source/CollapsibleTableViewHeaderView.xib deleted file mode 100644 index ea585066..00000000 --- a/Source/CollapsibleTableViewHeaderView.xib +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Source/EditorViewController.swift b/Source/EditorViewController.swift index 5c86433c..89977392 100644 --- a/Source/EditorViewController.swift +++ b/Source/EditorViewController.swift @@ -119,9 +119,6 @@ class EditorViewController: UIViewController { navigationController?.delegate = self navigationController?.toolbar.isTranslucent = true navigationController?.toolbar.clipsToBounds = true - if let splitVC = splitViewController as? PBHSplitViewController { - splitVC.editorViewController = self - } updatedByEditing = false self.observers.append(NotificationCenter.default.addObserver(forName: UIWindow.keyboardWillShowNotification, object: nil, diff --git a/Source/NotesTableViewCell.swift b/Source/NotesTableViewCell.swift deleted file mode 100644 index 699cb25a..00000000 --- a/Source/NotesTableViewCell.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// NotesTableViewCell.swift -// iOCNotes -// -// Created by Peter Hedlund on 7/31/19. -// Copyright © 2019 Peter Hedlund. All rights reserved. -// - -import UIKit - -class NoteTableViewCell: UITableViewCell { - - override func awakeFromNib() { - super.awakeFromNib() - // Initialization code - } - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - // Configure the view for the selected state - } - - @objc func selectCategory(sender: UIMenuController) { - print("Help 3") - } -} diff --git a/Source/PBHPreviewController.swift b/Source/PBHPreviewController.swift deleted file mode 100644 index 5f179aac..00000000 --- a/Source/PBHPreviewController.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// PBHPreviewController.swift -// iOCNotes -// -// Created by Peter Hedlund on 7/9/16. -// Copyright © 2016-2021 Peter Hedlund. All rights reserved. -// - -import UIKit - -class PreviewViewController: UIViewController { - - var content: String? - var noteTitle: String? - var noteDate: String? - - override func viewDidLoad() { - super.viewDidLoad() - var previewContent = "" - if let noteTitle = noteTitle { - previewContent.append("# \(noteTitle)\n") - } - if let noteDate = noteDate { - previewContent.append("*\(noteDate)*\n\n") - } - if let content = content { - do { - previewContent.append(content) - - let previewWebView = try PreviewWebView(markdown: content) { - print("Markdown was rendered.") - } - - previewWebView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(previewWebView) - NSLayoutConstraint.activate([ - previewWebView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - previewWebView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - previewWebView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - previewWebView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) - ]) - } catch { - // - } - } - self.navigationItem.title = noteTitle - } - -} diff --git a/Source/PBHSplitViewController.swift b/Source/PBHSplitViewController.swift deleted file mode 100644 index eae19c15..00000000 --- a/Source/PBHSplitViewController.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// PBHSplitViewController.swift -// iOCNotes -// -// Created by Peter Hedlund on 10/13/19. -// Copyright © 2019 Peter Hedlund. All rights reserved. -// - -import UIKit - -class PBHSplitViewController: UISplitViewController { - - var editorViewController: EditorViewController? - var notesTableViewController: NotesTableViewController? - - override func viewDidLoad() { - super.viewDidLoad() - delegate = self - preferredDisplayMode = .allVisible - } - - @IBAction func onFileNew(sender: Any?) { - notesTableViewController?.onAdd(sender: sender) - } - - @IBAction func onViewSync(sender: Any?) { - notesTableViewController?.onRefresh(sender: sender) - } -} - -extension PBHSplitViewController: UISplitViewControllerDelegate { - - func splitViewController(_ svc: UISplitViewController, willChangeTo displayMode: UISplitViewController.DisplayMode) { - guard svc == self else { - return - } - if displayMode == .allVisible || displayMode == .primaryOverlay { - self.editorViewController?.noteView.resignFirstResponder() - } - if traitCollection.horizontalSizeClass == .regular, - traitCollection.userInterfaceIdiom == .pad { - if displayMode == .allVisible { - editorViewController?.noteView.updateInsets(size: 50) - DispatchQueue.main.async { [weak self] in - self?.editorViewController?.noteView.isScrollEnabled = false - self?.editorViewController?.noteView.isScrollEnabled = true - } - } else { - if (UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { - editorViewController?.noteView.updateInsets(size: 178) - } else { - editorViewController?.noteView.updateInsets(size: 50) - } - } - } else { - editorViewController?.noteView.updateInsets(size: 20) - } - } - - func targetDisplayModeForAction(in svc: UISplitViewController) -> UISplitViewController.DisplayMode { - if svc.displayMode == .primaryHidden { - if svc.traitCollection.horizontalSizeClass == .regular, - [.landscapeLeft, .landscapeRight].contains(UIDevice.current.orientation) { - return .allVisible - } - return .primaryOverlay - } - return .primaryHidden - } - - override func collapseSecondaryViewController(_ secondaryViewController: UIViewController, for splitViewController: UISplitViewController) { - self.editorViewController?.note = nil - } - - func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool { - return true - } - - @available(iOS 14.0, *) - func splitViewController(_ splitViewController: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column) -> UISplitViewController.Column { - return .primary - } - - @objc func onRefreshButtonAction(sender: UIBarButtonItem) { - notesTableViewController?.onRefresh(sender: sender) - } - - @objc func onBackButtonAction(sender: UIBarButtonItem) { - editorViewController?.navigationController?.popViewController(animated: true) - } - - @objc func onPreviewButtonAction(sender: UIBarButtonItem) { - editorViewController?.onPreview(sender) - } - - @objc func onShareButtonAction(sender: UIBarButtonItem) { - editorViewController?.onActivities(sender) - } - - @IBAction func onPreferences(sender: Any) { - } - -} diff --git a/Source/Screens/Notes/NoteTableViewCell.swift b/Source/Screens/Notes/NoteTableViewCell.swift deleted file mode 100644 index 56731bd5..00000000 --- a/Source/Screens/Notes/NoteTableViewCell.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// NotesTableViewCell.swift -// iOCNotes -// -// Created by Peter Hedlund on 7/31/19. -// Copyright © 2019 Peter Hedlund. All rights reserved. -// - -import UIKit - -class NoteTableViewCell: UITableViewCell { - -} diff --git a/Source/Screens/Notes/NotesTableViewController.swift b/Source/Screens/Notes/NotesTableViewController.swift deleted file mode 100644 index 1781f92a..00000000 --- a/Source/Screens/Notes/NotesTableViewController.swift +++ /dev/null @@ -1,902 +0,0 @@ -// -// NotesTableViewController.swift -// iOCNotes -// -// Created by Peter Hedlund on 2/12/19. -// Copyright © 2019 Peter Hedlund. All rights reserved. -// - -import CoreData -import MobileCoreServices -import PKHUD -import SwiftMessages -import UIKit -import NextcloudKit -import SwiftyJSON -import Alamofire -import SwiftUI - -let detailSegueIdentifier = "showDetail" -let categorySegueIdentifier = "SelectCategorySegue" -let directeditingSe6436gueIdentifier = "directEditing" - -class NotesTableViewController: BaseUITableViewController, Logging, NSFetchedResultsControllerDelegate { - @IBOutlet var addBarButton: UIBarButtonItem! - @IBOutlet weak var refreshBarButton: UIBarButtonItem! - - var editorViewController: EditorViewController? - let appDelegate = UIApplication.shared.delegate as! AppDelegate - - private var networkHasBeenUnreachable = false - private var launching = true - - private lazy var fetchedResultsController: NSFetchedResultsController = { - let request = Note.fetchRequest() - request.fetchBatchSize = 288 - request.predicate = .allNotes - request.sortDescriptors = [ - NSSortDescriptor(key: "category", ascending: true), - NSSortDescriptor(key: "modified", ascending: false) - ] - - return NSFetchedResultsController( - fetchRequest: request, - managedObjectContext: NotesData.mainThreadContext, - sectionNameKeyPath: "sectionName", - cacheName: nil - ) - }() - - private var observers = [NSObjectProtocol]() - private var noteToAddOnViewDidLoad: String? - var isAddingFromButton = false - - private var contextMenuIndexPath: IndexPath? - private var noteExporter: NoteExporter? - private var dataSource: UITableViewDiffableDataSource? - private var notesByObjectID = [NSManagedObjectID: Note]() - - let logger = makeLogger() - - private var disclosureSections: DisclosureSections { - get { KeychainHelper.sectionExpandedInfo } - set { KeychainHelper.sectionExpandedInfo = newValue } - } - - private var dateFormat: DateFormatter { - let df = DateFormatter() - df.dateStyle = .short - df.timeStyle = .none; - df.doesRelativeDateFormatting = true - return df - } - - deinit { - for observer in self.observers { - NotificationCenter.default.removeObserver(observer) - } - } - - override func viewDidLoad() { - super.viewDidLoad() - - clearsSelectionOnViewWillAppear = false - - self.observers.append(NotificationCenter.default.addObserver(forName: UIContentSizeCategory.didChangeNotification, - object: nil, - queue: OperationQueue.main, - using: { [weak self] notification in - self?.tableView.reloadData() - })) - self.observers.append(NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, - object: nil, - queue: OperationQueue.main, - using: { [weak self] _ in - self?.didBecomeActive() - })) - self.observers.append(NotificationCenter.default.addObserver(forName: .offlineModeChanged, - object: nil, - queue: OperationQueue.main, - using: { [weak self] _ in - self?.refreshBarButton.isEnabled = NoteSessionManager.isOnline - })) - self.observers.append(NotificationCenter.default.addObserver(forName: .deletingNote, - object: nil, - queue: OperationQueue.main, - using: { [weak self] _ in - if let editor = self?.editorViewController, - let note = editor.note, - let currentIndexPath = self?.fetchedResultsController.indexPath(forObject: note), let tableView = self?.tableView { - self?.tableView(tableView, commit: .delete, forRowAt: currentIndexPath) - } - })) - self.observers.append(NotificationCenter.default.addObserver(forName: .syncNotes, - object: nil, - queue: OperationQueue.main, - using: { [weak self] _ in - self?.onRefresh(sender: nil) - })) - self.observers.append(NotificationCenter.default.addObserver(forName: .doneSelectingCategory, - object: nil, - queue: OperationQueue.main, - using: { [weak self] _ in - self?.tableView.reloadData() - })) - self.observers.append(NotificationCenter.default.addObserver(forName: .networkSuccess, - object: nil, - queue: OperationQueue.main, - using: { [weak self] _ in - HUD.hide() - self?.refreshBarButton.isEnabled = NoteSessionManager.isOnline - self?.addBarButton.isEnabled = true - })) - self.observers.append(NotificationCenter.default.addObserver(forName: .networkError, - object: nil, - queue: OperationQueue.main, - using: { [weak self] notification in - HUD.hide() - self?.refreshBarButton.isEnabled = NoteSessionManager.isOnline - self?.addBarButton.isEnabled = true - if let title = notification.userInfo?["Title"] as? String, - let message = notification.userInfo?["Message"] as? String { - var config = SwiftMessages.defaultConfig - config.interactiveHide = true - config.duration = .forever - config.preferredStatusBarStyle = .default - SwiftMessages.show(config: config, viewProvider: { - let view = MessageView.viewFromNib(layout: .cardView) - view.configureTheme(.error, iconStyle: .default) - view.configureDropShadow() - view.configureContent(title: title, - body: message, - iconImage: Icon.error.image, - iconText: nil, - buttonImage: nil, - buttonTitle: nil, - buttonTapHandler: nil - ) - return view - }) - } - }) - ) - - let nib = UINib(nibName: "CollapsibleTableViewHeaderView", bundle: nil) - tableView.register(nib, forHeaderFooterViewReuseIdentifier: "HeaderView") - navigationController?.navigationBar.isTranslucent = true - navigationController?.toolbar.isTranslucent = true - navigationController?.toolbar.clipsToBounds = true - - configureDataSource() - configureFetchedResultsController(performFetch: true) - - tableView.backgroundView = UIView() - tableView.dropDelegate = self - updateSectionExpandedInfo() - if let noteToAddOnViewDidLoad = noteToAddOnViewDidLoad { - addNote(content: noteToAddOnViewDidLoad) - self.noteToAddOnViewDidLoad = nil - } - tableView.reloadData() - definesPresentationContext = true - refreshBarButton.isEnabled = NoteSessionManager.isOnline - if let splitVC = splitViewController as? PBHSplitViewController { - splitVC.notesTableViewController = self - } - - view.backgroundColor = .systemBackground - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - addBarButton.isEnabled = true - refreshBarButton.isEnabled = NoteSessionManager.isOnline - startObservingSynchronizationState() - } - - override func viewDidAppear(_ animated: Bool) { - if launching { - didBecomeActive() - } - launching = false - } - - // MARK: - Store Observation - - private var observation: NSKeyValueObservation? - private var trackingToken: Any? - - func startObservingSynchronizationState() { - // Register a tracking block that re-runs when the observed value changes - trackingToken = withObservationTracking { - _ = Store.shared.isSynchronizing - } onChange: { [weak self] in - guard let self = self else { - return - } - - Task { @MainActor in - self.handleSynchronizationStateChange() - } - } - } - - func handleSynchronizationStateChange() { - if Store.shared.isSynchronizing { - beginRefreshing() - } else { - endRefreshing() - } - } - - /// - /// Update the user interface to reflect the active update. - /// - /// Outsourced into dedicated methods due to multiple callers. - /// - func beginRefreshing() { - refreshBarButton.isEnabled = false - addBarButton.isEnabled = false - } - - /// - /// Update the user interface to reflect the completed update. - /// - /// Outsourced into dedicated methods due to multiple callers. - /// - func endRefreshing() { - addBarButton.isEnabled = true - refreshBarButton.isEnabled = NoteSessionManager.isOnline - tableView.reloadData() - refreshControl?.endRefreshing() - } - - // MARK: - Public functions - - func configureFetchedResultsController(performFetch: Bool) { - fetchedResultsController.delegate = self - - guard performFetch else { - return - } - - do { - try fetchedResultsController.performFetch() - applySnapshot(animatingDifferences: false) - } catch { - logger.error("Could not fetch notes") - } - } - - func disableFetchedResultsController() { - fetchedResultsController.delegate = nil - } - - // MARK: - Table view data source - - private func configureDataSource() { - dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, objectID in - guard let self, - let note = self.note(for: objectID), - let cell = tableView.dequeueReusableCell(withIdentifier: "NoteCell", for: indexPath) as? NoteTableViewCell else { - return UITableViewCell() - } - - self.configureCell(cell, with: note) - return cell - } - tableView.dataSource = dataSource - } - - private func applySnapshot(animatingDifferences: Bool) { - var snapshot = NSDiffableDataSourceSnapshot() - var allItemIDs = [NSManagedObjectID]() - var notesByObjectID = [NSManagedObjectID: Note]() - - guard let sections = fetchedResultsController.sections else { - self.notesByObjectID = [:] - dataSource?.apply(snapshot, animatingDifferences: animatingDifferences) - return - } - - for section in sections { - let title = section.name - snapshot.appendSections([title]) - - let isCollapsed = disclosureSections.first(where: { $0.title == title })?.collapsed ?? false - guard !isCollapsed, - let notes = section.objects as? [Note] else { - continue - } - - for note in notes { - notesByObjectID[note.objectID] = note - } - let ids = notes.map(\.objectID) - allItemIDs.append(contentsOf: ids) - snapshot.appendItems(ids, toSection: title) - } - - self.notesByObjectID = notesByObjectID - snapshot.reloadItems(allItemIDs) - dataSource?.apply(snapshot, animatingDifferences: animatingDifferences) { [weak self] in - self?.tableView.setNeedsLayout() - self?.tableView.layoutIfNeeded() - } - } - - private func sectionTitle(at sectionIndex: Int) -> String? { - guard let dataSource else { - return nil - } - - let sections = dataSource.snapshot().sectionIdentifiers - guard sections.indices.contains(sectionIndex) else { - return nil - } - - return sections[sectionIndex] - } - - private func note(at indexPath: IndexPath) -> Note? { - guard let objectID = dataSource?.itemIdentifier(for: indexPath) else { - return nil - } - - return note(for: objectID) - } - - private func note(for objectID: NSManagedObjectID) -> Note? { - return notesByObjectID[objectID] - } - - private func isValid(indexPath: IndexPath) -> Bool { - guard let sections = fetchedResultsController.sections else { - return false - } - - guard indexPath.section >= 0, indexPath.section < sections.count else { - return false - } - - return indexPath.row >= 0 && indexPath.row < sections[indexPath.section].numberOfObjects - } - - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - let sectionHeaderView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "HeaderView") as! CollapsibleTableViewHeaderView - let title = sectionTitle(at: section) ?? "" - let displayTitle = title.isEmpty ? "" : title - sectionHeaderView.sectionTitle = title - sectionHeaderView.sectionIndex = section - sectionHeaderView.delegate = self - sectionHeaderView.titleLabel.text = displayTitle - sectionHeaderView.collapsed = disclosureSections.first(where: { $0.title == title })?.collapsed ?? false - return sectionHeaderView - } - - override func tableView(_ tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat { - return 44 - } - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return 44 - } - - override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return CGFloat.leastNormalMagnitude - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let label = UILabel(frame: CGRect(origin: .zero, size: CGSize(width: Int.max, height: Int.max))) - label.text = "test" - label.font = UIFont.preferredFont(forTextStyle: .headline) - label.sizeToFit() - let height1 = label.frame.size.height - - label.font = UIFont.preferredFont(forTextStyle: .footnote) - label.sizeToFit() - let height2 = label.frame.size.height - - return (height1 + height2) * 1.7 - } - - fileprivate func configureCell(_ cell: NoteTableViewCell, with note: Note) { - cell.textLabel?.font = .systemFont(ofSize: 17, weight: .medium) - cell.backgroundColor = .ph_cellBackgroundColor - cell.contentView.backgroundColor = .ph_cellBackgroundColor - let selectedBackgroundView = UIView(frame: cell.frame) - selectedBackgroundView.backgroundColor = UIColor.ph_cellSelectionColor - cell.selectedBackgroundView = selectedBackgroundView - - cell.textLabel?.text = note.title - cell.backgroundColor = .clear - let date = Date(timeIntervalSince1970: note.modified) - cell.detailTextLabel?.text = dateFormat.string(from: date as Date) - cell.detailTextLabel?.font = UIFont.preferredFont(forTextStyle: .footnote) - cell.detailTextLabel?.textColor = .secondaryLabel - } - - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - return true - } - - // Override to support editing the table view. - override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { - if editingStyle == .delete { - guard let note = note(at: indexPath) else { return } - HUD.show(.progress) - if note == self.editorViewController?.note { - self.editorViewController?.note = nil - } - - NoteSessionManager.shared.delete(note: note, completion: { [weak self] in - if self?.isValid(indexPath: indexPath) ?? false { - var newIndex = 0 - if indexPath.row >= 0 { - newIndex = indexPath.row - } - var noteCount = 0 - if let sections = self?.fetchedResultsController.sections, - sections.count >= indexPath.section { - noteCount = sections[indexPath.section].numberOfObjects - } - if newIndex >= noteCount { - newIndex = noteCount - 1 - } - - if newIndex >= 0 && newIndex < noteCount, - let newNote = self?.fetchedResultsController.sections?[indexPath.section].objects?[newIndex] as? Note { - self?.editorViewController?.note = newNote - DispatchQueue.main.async { - self?.tableView.selectRow(at: IndexPath(row: newIndex, section: indexPath.section), animated: false, scrollPosition: .none) - } - } else { - self?.editorViewController?.note = nil - } - } - HUD.hide() - }) - } - } - - override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { - return .delete - } - - // MARK: - Navigation - - func isAvailableDirectEditing(identifier: String) -> Bool { - guard KeychainHelper.internalEditor == false else { - return false - } - - guard identifier == detailSegueIdentifier else { - return false - } - - guard KeychainHelper.directEditing else { - return false - } - - guard KeychainHelper.directEditingSupportsFileId else { - return false - } - - guard appDelegate.networkReachability == NKCommon.TypeReachability.reachableCellular || appDelegate.networkReachability == NKCommon.TypeReachability.reachableEthernetOrWiFi else { - return false - } - - return true - } - - func openTextWebView(note: Note) { - guard let account = KeychainHelper.account else { - return - } - - let notesPath = KeychainHelper.notesPath - - NextcloudKit.shared.textOpenFile(fileNamePath: notesPath, fileId: String(note.id), editor: "text", account: account) { account, url, data, error in - if error == .success, let url = url, let viewController: NCViewerNextcloudText = UIStoryboard(name: "NCViewerNextcloudText", bundle: nil).instantiateInitialViewController() as? NCViewerNextcloudText { - viewController.editor = "text" - viewController.link = url - viewController.fileName = note.title - viewController.modalPresentationStyle = .fullScreen - self.navigationController?.present(viewController, animated: true) - } else { - let alert = UIAlertController(title: "Error", message: "Cannot open file for direct editing: \(error.localizedDescription)", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Default action"), style: .default)) - self.present(alert, animated: true, completion: nil) - } - } - - } - - override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool { - if isAvailableDirectEditing(identifier: identifier), let cell = sender as? UITableViewCell, let cellIndexPath = tableView.indexPath(for: cell) { - guard let note = note(at: cellIndexPath) else { return false } - openTextWebView(note: note) - return false - } else { - return true - } - } - - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - switch segue.identifier { - case detailSegueIdentifier: - var selectedIndexPath = IndexPath(row: 0, section: 0) - if let cell = sender as? UITableViewCell, let cellIndexPath = tableView.indexPath(for: cell) { - selectedIndexPath = cellIndexPath - } - if let navigationController = segue.destination as? UINavigationController, - let editorController = navigationController.topViewController as? EditorViewController { - editorViewController = editorController - guard let note = note(at: selectedIndexPath) else { return } - editorController.note = note - editorController.isNewNote = isAddingFromButton - isAddingFromButton = false - editorController.navigationItem.leftItemsSupplementBackButton = true - editorController.navigationItem.title = note.title - if splitViewController?.displayMode == .oneBesideSecondary || splitViewController?.displayMode == .oneOverSecondary { - UIView.animate(withDuration: 0.3, animations: { - self.splitViewController?.preferredDisplayMode = .secondaryOnly - }, completion: nil) - } - } - default: - break - } - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - editorViewController?.isNewNote = false - } - - private func showRenameAlert(for indexPath: IndexPath) { - var nameTextField: UITextField? - guard let note = note(at: indexPath) else { return } - let alertController = UIAlertController(title: NSLocalizedString("Note Title", comment: "Title of alert to change title"), - message: NSLocalizedString("Rename the note", comment: "Message of alert to change title"), - preferredStyle: .alert) - alertController.addTextField { textField in - nameTextField = textField - textField.text = note.title - textField.keyboardType = .default - } - let cancelAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "Caption of Cancel button"), style: .cancel, handler: nil) - let renameAction = UIAlertAction(title: NSLocalizedString("Rename", comment: "Caption of Rename button"), style: .default) { (action) in - guard let newName = nameTextField?.text, - !newName.isEmpty, - newName != note.title else { - return - } - note.title = newName - NoteSessionManager.shared.update(note: note, completion: nil) - } - alertController.addAction(cancelAction) - alertController.addAction(renameAction) - present(alertController, animated: true, completion: nil) - } - - public override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - guard let note = note(at: indexPath) else { return nil } - contextMenuIndexPath = indexPath - var actions = [UIAction]() - - if isNextcloud(), - KeychainHelper.notesApiVersion != Router.defaultApiVersion { - let renameAction = UIAction(title: NSLocalizedString("Rename…", comment: "Action to change title of a note"), image: UIImage(systemName: "square.and.pencil")) { [weak self] action in - self?.showRenameAlert(for: indexPath) - } - actions.append(renameAction) - } - if isNextcloud() { - let categoryAction = UIAction(title: NSLocalizedString("Category…", comment: "Action to change category of a note"), image: UIImage(named: "categories")) { [weak self] _ in - self?.showCategories(indexPath: indexPath) - } - actions.append(categoryAction) - } - let shareAction = UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up")) { [weak self] action in - guard let self = self, - !note.content.isEmpty else { - return - } - self.noteExporter = NoteExporter(title: note.title, text: note.content, viewController: self, from: CGRect(origin: point, size: CGSize(width: 3, height: 3)), in: tableView) - self.noteExporter?.showMenu() - } - actions.append(shareAction) - - let deleteAction = UIAction(title: NSLocalizedString("Delete", comment: "Action to delete a note"), image: (UIImage(systemName: "trash")), identifier: UIAction.Identifier("deleteAction"), discoverabilityTitle: nil, attributes: .destructive, state: .off, handler: { [weak self] _ in - self?.tableView(tableView, commit: .delete, forRowAt: indexPath) - }) - actions.append(deleteAction) - - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ -> UIMenu? in - return UIMenu(title: "", children: actions) - } - - } - - @IBAction func onPullToRefresh(_ sender: Any) { - onRefresh(sender: sender) - } - - @IBAction func onRefresh(sender: Any?) { - guard NoteSessionManager.isOnline else { - refreshControl?.endRefreshing() - return - } - - beginRefreshing() - - NoteSessionManager.shared.sync { [weak self] in - self?.endRefreshing() - } - } - - @IBAction func onAdd(sender: Any?) { - isAddingFromButton = true - addNote(content: "") - } - - func searchNote(text: String) { - var predicate: NSPredicate? - if !text.isEmpty { - let matchingText = NSPredicate(format: "(title contains[c] %@) || (content contains[cd] %@)", text, text) - predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [.allNotes, matchingText]) - } else { - predicate = .allNotes - } - fetchedResultsController.fetchRequest.predicate = predicate - do { - try fetchedResultsController.performFetch() - applySnapshot(animatingDifferences: false) - } catch { } - } - - func addNote(content: String) { - guard isViewLoaded else { - noteToAddOnViewDidLoad = content - return - } - HUD.show(.progress) - NoteSessionManager.shared.add(content: content, category: "", completion: { [weak self] note in - if note != nil { - let indexPath = IndexPath(row: 0, section: 0) - if self?.isValid(indexPath: indexPath) ?? false, - let collapsedInfo = self?.disclosureSections.first(where: { $0.title == Constants.noCategory }), - !collapsedInfo.collapsed { - self?.tableView.selectRow(at: indexPath, animated: true, scrollPosition: .top) - } - self?.editorViewController?.isNewNote = true - if (self?.isAvailableDirectEditing(identifier: detailSegueIdentifier)) ?? false, let note = note { - self?.openTextWebView(note: note) - } else { - self?.performSegue(withIdentifier: detailSegueIdentifier, sender: self) - } - } - HUD.hide() - }) - } - - func updateSectionExpandedInfo() { - let knownSectionTitles = Set(disclosureSections.map { $0.title }) - if let sections = fetchedResultsController.sections { - if sections.isEmpty { - disclosureSections = [] - } else { - let newSectionTitles = Set(sections.map { $0.name }) - let deleted = knownSectionTitles.subtracting(newSectionTitles) - let added = newSectionTitles.subtracting(knownSectionTitles) - var sectionCollapsedInfo = disclosureSections.filter { !deleted.contains($0.title) } - for newSection in added { - sectionCollapsedInfo.append(DisclosureSection(title: newSection, collapsed: false)) - } - disclosureSections = sectionCollapsedInfo - } - } - } - - // MARK: Notification Callbacks - - private func reachabilityChanged() { - // - } - - private func didBecomeActive() { - if KeychainHelper.syncOnStart { - onRefresh(sender: nil) - } else if KeychainHelper.dbReset { - Note.reset() - KeychainHelper.dbReset = false - try? fetchedResultsController.performFetch() - updateSectionExpandedInfo() - applySnapshot(animatingDifferences: false) - } - addBarButton.isEnabled = true - refreshBarButton.isEnabled = NoteSessionManager.isOnline - } - - fileprivate func showCategories(indexPath: IndexPath) { - let categories = fetchedResultsController.fetchedObjects?.compactMap({ (note) -> String? in - return note.category - }) - let storyboard = UIStoryboard(name: "Categories", bundle: Bundle.main) - if let navController = storyboard.instantiateViewController(withIdentifier: "CategoryNavigationController") as? UINavigationController, - let categoryController = navController.topViewController as? CategoryTableViewController, - let categories = categories { - guard let note = note(at: indexPath) else { return } - categoryController.categories = categories.removingDuplicates() - categoryController.note = note - self.present(navController, animated: true, completion: nil) - } - } - - override func applyTheme(brandColor: UIColor, brandTextColor: UIColor) { - addBarButton.tintColor = brandColor - refreshBarButton.tintColor = brandColor - } -} - -// MARK: - NSFetchedResultsControllerDelegate - -extension NotesTableViewController { - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - updateSectionExpandedInfo() - applySnapshot(animatingDifferences: true) - } -} - -// MARK: - UISearchResultsUpdating - -extension NotesTableViewController: UISearchResultsUpdating { - func updateSearchResults(for searchController: UISearchController) { - var predicate: NSPredicate? - if let text = searchController.searchBar.text, !text.isEmpty { - let matchingText = NSPredicate(format: "(title contains[c] %@) || (content contains[cd] %@)", text, text) - predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [.allNotes, matchingText]) - } else { - predicate = .allNotes - } - fetchedResultsController.fetchRequest.predicate = predicate - do { - try fetchedResultsController.performFetch() - applySnapshot(animatingDifferences: false) - } catch { } - } - -} - -// MARK: - UITableViewDropDelegate - -extension NotesTableViewController: UITableViewDropDelegate { - func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool { - if !session.items.isEmpty, - session.hasItemsConforming(toTypeIdentifiers: [kUTTypeText as String, - kUTTypeXML as String, - kUTTypeHTML as String, - kUTTypeJSON as String, - kUTTypePlainText as String]) { - return true - } - return false - } - - func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal { - if destinationIndexPath?.section != 0 { - return UITableViewDropProposal(operation: .forbidden, intent: .automatic) - } else { - return UITableViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath) - } - } - - func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) { - for item in coordinator.session.items { - item.itemProvider.loadDataRepresentation(forTypeIdentifier: kUTTypeText as String) { (data, _) in - if let contentData = data, - let content = String(bytes: contentData, encoding: .utf8) { - NoteSessionManager.shared.add(content: content, category: "") - } - } - } - } - -} - -// MARK: - CollapsibleTableViewHeaderViewDelegate - -extension NotesTableViewController: CollapsibleTableViewHeaderViewDelegate { - func toggleSection(_ header: CollapsibleTableViewHeaderView, sectionTitle: String, sectionIndex: Int) { - var sectionCollapsedInfo = disclosureSections - if let info = sectionCollapsedInfo.first(where: { $0.title == sectionTitle }), - let index = sectionCollapsedInfo.firstIndex(where: { $0.title == sectionTitle }) { - let collapsed = info.collapsed - sectionCollapsedInfo.remove(at: index) - sectionCollapsedInfo.insert(DisclosureSection(title: info.title, collapsed: !collapsed), at: index) - disclosureSections = sectionCollapsedInfo - header.collapsed = !collapsed - applySnapshot(animatingDifferences: true) - } - } -} - -// MARK: - UIAdaptivePresentationControllerDelegate - -extension NotesTableViewController: UIAdaptivePresentationControllerDelegate { - func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - addBarButton.isEnabled = true - refreshBarButton.isEnabled = NoteSessionManager.isOnline - } -} - -// MARK: - Equatable - -extension Array where Element: Equatable { - func removingDuplicates() -> Array { - return reduce(into: []) { result, element in - if !result.contains(element) { - result.append(element) - } - } - } -} - -// MARK: - allNotes - -extension NSPredicate { - static var allNotes: NSPredicate { - return NSPredicate(format: "deleteNeeded == %@", NSNumber(value: false)) - } - -} - -// MARK: - UIViewControllerRepresentable - -struct NotesTableViewControllerRepresentable: UIViewControllerRepresentable { - @Binding var addNote: Bool - @Binding var searchText: String - - class Coordinator: NSObject { - var parent: NotesTableViewControllerRepresentable - weak var viewController: NotesTableViewController? - - init(_ parent: NotesTableViewControllerRepresentable) { - self.parent = parent - } - - func searchNote(text: String) { - viewController?.searchNote(text: text) - } - - func addNote() { - viewController?.isAddingFromButton = true - viewController?.addNote(content: "") - - parent.addNote = false - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeUIViewController(context: Context) -> UIViewController { - let storyboard = UIStoryboard(name: "Main_iPhone", bundle: nil) - - let viewController = storyboard.instantiateViewController(withIdentifier: "Notes") as? NotesTableViewController - context.coordinator.viewController = viewController - - return viewController ?? UIViewController() - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) { - if addNote { - context.coordinator.addNote() - } - - context.coordinator.searchNote(text: searchText) - } -} diff --git a/Utils/KeychainHelper.swift b/Utils/KeychainHelper.swift index 599ace69..2cb74959 100644 --- a/Utils/KeychainHelper.swift +++ b/Utils/KeychainHelper.swift @@ -170,6 +170,18 @@ struct KeychainHelper { } } + static var groupByCategory: Bool { + get { + if UserDefaults.standard.object(forKey: "GroupByCategory") == nil { + return true + } + return UserDefaults.standard.bool(forKey: "GroupByCategory") + } + set { + UserDefaults.standard.set(newValue, forKey: "GroupByCategory") + } + } + static var sectionExpandedInfo: DisclosureSections { get { if let data = UserDefaults.standard.value(forKey: "Sections") as? Data, diff --git a/iOCNotes.xcodeproj/project.pbxproj b/iOCNotes.xcodeproj/project.pbxproj index c1108ffd..dcd1ba3d 100644 --- a/iOCNotes.xcodeproj/project.pbxproj +++ b/iOCNotes.xcodeproj/project.pbxproj @@ -17,18 +17,15 @@ BD86DD8522B9CDD500115E5D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD86DD8422B9CDD500115E5D /* KeychainHelper.swift */; }; BD92271B22CD3743004E2408 /* UtilityExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD92271A22CD3743004E2408 /* UtilityExtensions.swift */; }; BDD015A1234CF551000BA001 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BDD015A0234CEF86000BA001 /* Colors.xcassets */; }; - BDD015A32353F7D4000BA001 /* PBHSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDD015A22353F7D4000BA001 /* PBHSplitViewController.swift */; }; D004DB7723920E820080A7D1 /* Preview.bundle in Resources */ = {isa = PBXBuildFile; fileRef = D004DB7623920E820080A7D1 /* Preview.bundle */; }; + D004DB7F23948F3D0080A7D1 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D004DB7E23948F3D0080A7D1 /* SceneDelegate.swift */; }; D00CDFBF194E65B3007505E9 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D00CDFC1194E65B4007505E9 /* Localizable.strings */; }; D01067E22213BB5E0047E090 /* NoteProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01067E12213BB5E0047E090 /* NoteProtocol.swift */; }; D01067E42213BDF30047E090 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01067E32213BDF20047E090 /* AppDelegate.swift */; }; - D004DB7F23948F3D0080A7D1 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D004DB7E23948F3D0080A7D1 /* SceneDelegate.swift */; }; - D01067E62213C17D0047E090 /* NotesTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01067E52213C17D0047E090 /* NotesTableViewController.swift */; }; D017D4241D3B03BE00B21443 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D017D4231D3B03BE00B21443 /* WebKit.framework */; }; D02256C71891FD7C0038232A /* defaults.plist in Resources */ = {isa = PBXBuildFile; fileRef = D02256C61891FD7C0038232A /* defaults.plist */; }; D0225709189745C40038232A /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = D0225708189745C40038232A /* Settings.bundle */; }; D02D15CC2905D756006ADCBB /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = D02D15CB2905D756006ADCBB /* OpenSSL */; }; - D03493B722F2837500E1A9B0 /* NoteTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03493B622F2837500E1A9B0 /* NoteTableViewCell.swift */; }; D03493B922F3CC5700E1A9B0 /* CategoryTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03493B822F3CC5700E1A9B0 /* CategoryTableViewController.swift */; }; D0545F23230901D30001D165 /* PBHOpenInActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0545F22230901D30001D165 /* PBHOpenInActivity.swift */; }; D0595C7C238632610004C4E8 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = D0595C7B238632610004C4E8 /* KeychainAccess */; }; @@ -44,8 +41,6 @@ D095AE45245BB25F00A7EF62 /* NoteSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01067DD220BCE3C0047E090 /* NoteSessionManager.swift */; }; D09E430C1E2C46930010E4B3 /* Main_iPhone.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D09E430E1E2C46930010E4B3 /* Main_iPhone.storyboard */; }; D09E6451248B3A7C003FB4C9 /* OCSProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09E6450248B3A7C003FB4C9 /* OCSProtocol.swift */; }; - D0B6D4AE22F91DDA0037E073 /* CollapsibleTableViewHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B6D4AC22F91DDA0037E073 /* CollapsibleTableViewHeaderView.swift */; }; - D0B6D4AF22F91DDA0037E073 /* CollapsibleTableViewHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D0B6D4AD22F91DDA0037E073 /* CollapsibleTableViewHeaderView.xib */; }; D0BCFE6A2454BF40007C4CA3 /* Categories.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D0BCFE6C2454BF40007C4CA3 /* Categories.storyboard */; }; D0C1C39F2462448D00AAC4A8 /* SyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C1C39E2462448D00AAC4A8 /* SyncOperation.swift */; }; D0C876DF248F18700052EA63 /* Version in Frameworks */ = {isa = PBXBuildFile; productRef = D0C876DE248F18700052EA63 /* Version */; }; @@ -126,7 +121,6 @@ BD92272622D7874E004E2408 /* Notepad.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Notepad.framework; path = Carthage/Build/iOS/Notepad.framework; sourceTree = ""; }; BDC5FF8A23EB996200E0EB17 /* Notepad.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Notepad.framework; path = Carthage/Build/Mac/Notepad.framework; sourceTree = ""; }; BDD015A0234CEF86000BA001 /* Colors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Colors.xcassets; sourceTree = ""; }; - BDD015A22353F7D4000BA001 /* PBHSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PBHSplitViewController.swift; sourceTree = ""; }; D004DB652391EBA70080A7D1 /* SwiftMessages.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftMessages.framework; path = Carthage/Build/macCatalyst/SwiftMessages.framework; sourceTree = ""; }; D004DB7623920E820080A7D1 /* Preview.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Preview.bundle; sourceTree = ""; }; D004DB7E23948F3D0080A7D1 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -138,14 +132,11 @@ D01067DD220BCE3C0047E090 /* NoteSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteSessionManager.swift; sourceTree = ""; }; D01067E12213BB5E0047E090 /* NoteProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoteProtocol.swift; sourceTree = ""; }; D01067E32213BDF20047E090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - D01067E52213C17D0047E090 /* NotesTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesTableViewController.swift; sourceTree = ""; }; D017D4231D3B03BE00B21443 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; - D018034923B037B1001CA4FC /* CategoriesSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoriesSceneDelegate.swift; sourceTree = ""; }; D02256C61891FD7C0038232A /* defaults.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = defaults.plist; sourceTree = ""; }; D0225708189745C40038232A /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; D0225710189848060038232A /* libc++.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = "libc++.dylib"; path = "usr/lib/libc++.dylib"; sourceTree = SDKROOT; }; D0225712189848120038232A /* libz.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libz.dylib; path = usr/lib/libz.dylib; sourceTree = SDKROOT; }; - D03493B622F2837500E1A9B0 /* NoteTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteTableViewCell.swift; sourceTree = ""; }; D03493B822F3CC5700E1A9B0 /* CategoryTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryTableViewController.swift; sourceTree = ""; }; D03E93DC2734BEF30066BC8F /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Main_iPhone.strings; sourceTree = ""; }; D03E93DE2734BEF30066BC8F /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Categories.strings; sourceTree = ""; }; @@ -174,8 +165,6 @@ D0B0F39F2734C11200E51C3F /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; D0B0F3A02734C11700E51C3F /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; D0B0F3A12734C11F00E51C3F /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = ""; }; - D0B6D4AC22F91DDA0037E073 /* CollapsibleTableViewHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTableViewHeaderView.swift; sourceTree = ""; }; - D0B6D4AD22F91DDA0037E073 /* CollapsibleTableViewHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CollapsibleTableViewHeaderView.xib; sourceTree = ""; }; D0BCFE6B2454BF40007C4CA3 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Categories.storyboard; sourceTree = ""; }; D0BCFE6E2454BF46007C4CA3 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Categories.strings; sourceTree = ""; }; D0BCFE702454BF46007C4CA3 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Categories.strings; sourceTree = ""; }; @@ -367,16 +356,12 @@ F3F734552C6FABEA007C8C0B /* Screens */, D01067E32213BDF20047E090 /* AppDelegate.swift */, D0BCFE6C2454BF40007C4CA3 /* Categories.storyboard */, - D018034923B037B1001CA4FC /* CategoriesSceneDelegate.swift */, D03493B822F3CC5700E1A9B0 /* CategoryTableViewController.swift */, D075B1BD29074E6B0085D64E /* CertificateViewController.swift */, - D0B6D4AC22F91DDA0037E073 /* CollapsibleTableViewHeaderView.swift */, - D0B6D4AD22F91DDA0037E073 /* CollapsibleTableViewHeaderView.xib */, BDD015A0234CEF86000BA001 /* Colors.xcassets */, D06882ED22BB146200CEBC1F /* EditorViewController.swift */, D09E430E1E2C46930010E4B3 /* Main_iPhone.storyboard */, D0545F22230901D30001D165 /* PBHOpenInActivity.swift */, - BDD015A22353F7D4000BA001 /* PBHSplitViewController.swift */, D004DB7623920E820080A7D1 /* Preview.bundle */, D09538421D32A56A006BB78E /* PreviewViewController.swift */, D0E60F27277801F8009CF78F /* PreviewWebView.swift */, @@ -453,8 +438,6 @@ isa = PBXGroup; children = ( D059F7DA1D40596D00C252F2 /* NoteExporter.swift */, - D01067E52213C17D0047E090 /* NotesTableViewController.swift */, - D03493B622F2837500E1A9B0 /* NoteTableViewCell.swift */, ); path = Notes; sourceTree = ""; @@ -639,7 +622,6 @@ D00CDFBF194E65B3007505E9 /* Localizable.strings in Resources */, D004DB7723920E820080A7D1 /* Preview.bundle in Resources */, F7BB8B7229915A650010D2F7 /* Launch Screen.storyboard in Resources */, - D0B6D4AF22F91DDA0037E073 /* CollapsibleTableViewHeaderView.xib in Resources */, D02256C71891FD7C0038232A /* defaults.plist in Resources */, D09E430C1E2C46930010E4B3 /* Main_iPhone.storyboard in Resources */, D0225709189745C40038232A /* Settings.bundle in Resources */, @@ -695,7 +677,6 @@ F3E3C8D92C6B7C2200A80504 /* NCBrand.swift in Sources */, D095384A1D3313F8006BB78E /* PreviewViewController.swift in Sources */, D0E60F152772D809009CF78F /* SettingsProtocol.swift in Sources */, - BDD015A32353F7D4000BA001 /* PBHSplitViewController.swift in Sources */, D095AE45245BB25F00A7EF62 /* NoteSessionManager.swift in Sources */, D0E60F5E277FADD5009CF78F /* Theme.swift in Sources */, D07F5E8E2207B6FF00528E90 /* Router.swift in Sources */, @@ -708,8 +689,6 @@ F3F734512C6FA550007C8C0B /* Notes.xcdatamodeld in Sources */, F7F0812B299D0B53006A2041 /* NCViewerNextcloudText.swift in Sources */, D0E38B0324930C410075B7F0 /* Throttler.swift in Sources */, - D01067E62213C17D0047E090 /* NotesTableViewController.swift in Sources */, - D0B6D4AE22F91DDA0037E073 /* CollapsibleTableViewHeaderView.swift in Sources */, BD92271B22CD3743004E2408 /* UtilityExtensions.swift in Sources */, D0E60F71277FADD6009CF78F /* CheckBoxTapHandler.swift in Sources */, D0E60F5F277FADD5009CF78F /* Element.swift in Sources */, @@ -722,7 +701,6 @@ D06882EE22BB146200CEBC1F /* EditorViewController.swift in Sources */, D0E60F59277FADD5009CF78F /* Style.swift in Sources */, F31EAF2A2F759FFD00090ECE /* MarkdownRenderer.swift in Sources */, - D03493B722F2837500E1A9B0 /* NoteTableViewCell.swift in Sources */, F3F734522C6FA550007C8C0B /* NotesData.swift in Sources */, D01067E22213BB5E0047E090 /* NoteProtocol.swift in Sources */, D059F7DB1D40596D00C252F2 /* NoteExporter.swift in Sources */, diff --git a/iOCNotes/Views/NoteRowView.swift b/iOCNotes/Views/NoteRowView.swift new file mode 100644 index 00000000..51f58a44 --- /dev/null +++ b/iOCNotes/Views/NoteRowView.swift @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +/// +/// A single row in ``NotesListView`` showing the note title, a short content preview and the modification date. +/// +/// The row renders an immutable snapshot so its content and identity do not depend on live `NSManagedObject` +/// change notifications. +/// +struct NoteRowView: View { + private let title: String + private let snippet: String + private let modified: Double + private let favorite: Bool + + init(row: NoteListRow) { + title = row.title + snippet = row.snippet + modified = row.modified + favorite = row.favorite + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .none + formatter.doesRelativeDateFormatting = true + return formatter + }() + + private var modifiedText: String { + Self.dateFormatter.string(from: Date(timeIntervalSince1970: modified)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 8) { + Text(title) + .font(.headline) + .lineLimit(1) + + if favorite { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(.yellow) + } + } + + if !snippet.isEmpty { + Text(snippet) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Text(modifiedText) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 2) + .contentShape(.rect) + } +} diff --git a/iOCNotes/Views/NotesListModel.swift b/iOCNotes/Views/NotesListModel.swift new file mode 100644 index 00000000..eb1ed96a --- /dev/null +++ b/iOCNotes/Views/NotesListModel.swift @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH +// SPDX-License-Identifier: GPL-3.0-or-later + +import CoreData +import Observation +import os +import SwiftUI + +/// +/// Immutable values needed to identify, render and act on a note list row. +/// +struct NoteListRow: Equatable, Identifiable { + private static let snippetLength = 512 + + let id: NSManagedObjectID + let title: String + let snippet: String + let modified: Double + let favorite: Bool + + init(note: Note) { + id = note.objectID + title = note.title + snippet = Self.snippet(from: note.content) + modified = note.modified + favorite = note.favorite + } + + private static func snippet(from content: String) -> String { + guard let newline = content.firstIndex(of: "\n") else { return "" } + let bodyStart = content.index(after: newline) + return content[bodyStart...] + .prefix(snippetLength) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +/// +/// A single group of notes shown in ``NotesListView``. +/// +/// When notes are sorted by modification date instead of grouped by category, a single section with an +/// empty ``title`` is produced and rendered without a header. +/// +struct NoteListSection: Equatable, Identifiable { + let title: String + let rows: [NoteListRow] + + var id: String { title } + var hasHeader: Bool { !title.isEmpty } +} + +/// +/// Observable data source for the SwiftUI notes list. +/// +/// Wraps an `NSFetchedResultsController` so the proven Core Data behaviour (category grouping, search +/// predicate, live updates) is reused while exposing plain value types to SwiftUI. +/// +@Observable +final class NotesListModel: NSObject, Logging, NSFetchedResultsControllerDelegate { + let logger = makeLogger() + + private(set) var sections: [NoteListSection] = [] + + @ObservationIgnored private var fetchedResultsController: NSFetchedResultsController? + @ObservationIgnored private var searchText = "" + private var collapsedTitles: Set + + private(set) var groupByCategory: Bool + + override init() { + groupByCategory = KeychainHelper.groupByCategory + collapsedTitles = Set(KeychainHelper.sectionExpandedInfo.filter { $0.collapsed }.map { $0.title }) + super.init() + configure() + } + + // MARK: - Sorting + + func setGroupByCategory(_ value: Bool) { + guard value != groupByCategory else { return } + groupByCategory = value + KeychainHelper.groupByCategory = value + configure() + } + + // MARK: - Search + + func search(for text: String) { + guard text != searchText else { return } + searchText = text + configure() + } + + // MARK: - Section expansion + + func isExpanded(_ title: String) -> Bool { + !collapsedTitles.contains(title) + } + + func setExpanded(_ expanded: Bool, for title: String) { + if expanded { + collapsedTitles.remove(title) + } else { + collapsedTitles.insert(title) + } + persistCollapsedState() + } + + private func persistCollapsedState() { + // Persist the full set of collapsed titles, not just the currently visible sections, so that a section + // filtered out by an active search does not lose its collapsed state. + KeychainHelper.sectionExpandedInfo = collapsedTitles + .sorted() + .map { DisclosureSection(title: $0, collapsed: true) } + } + + // MARK: - Fetching + + private func configure() { + let request = Note.fetchRequest() + request.fetchBatchSize = 288 + request.predicate = predicate(for: searchText) + + if groupByCategory { + request.sortDescriptors = [ + NSSortDescriptor(key: "category", ascending: true), + NSSortDescriptor(key: "modified", ascending: false), + NSSortDescriptor(key: "id", ascending: true), + NSSortDescriptor(key: "guid", ascending: true) + ] + } else { + request.sortDescriptors = [ + NSSortDescriptor(key: "modified", ascending: false), + NSSortDescriptor(key: "id", ascending: true), + NSSortDescriptor(key: "guid", ascending: true) + ] + } + + let controller = NSFetchedResultsController( + fetchRequest: request, + managedObjectContext: NotesData.mainThreadContext, + sectionNameKeyPath: groupByCategory ? "sectionName" : nil, + cacheName: nil + ) + controller.delegate = self + fetchedResultsController = controller + + do { + try controller.performFetch() + rebuildSections() + } catch { + logger.error("Failed to fetch notes: \(error.localizedDescription, privacy: .public)") + sections = [] + } + } + + private func predicate(for text: String) -> NSPredicate { + guard !text.isEmpty else { + return .allNotes + } + let matching = NSPredicate(format: "(title contains[c] %@) || (content contains[cd] %@)", text, text) + return NSCompoundPredicate(andPredicateWithSubpredicates: [.allNotes, matching]) + } + + private func rebuildSections() { + guard let fetchedSections = fetchedResultsController?.sections else { + sections = [] + return + } + + let updatedSections = fetchedSections.map { section in + let title = groupByCategory ? section.name : "" + let notes = (section.objects as? [Note]) ?? [] + return NoteListSection(title: title, rows: notes.map(NoteListRow.init)) + } + + guard updatedSections != sections else { return } + sections = updatedSections + } + + func note(for id: NSManagedObjectID) -> Note? { + try? NotesData.mainThreadContext.existingObject(with: id) as? Note + } + + // MARK: - NSFetchedResultsControllerDelegate + + @objc + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + rebuildSections() + } +} + +// MARK: - allNotes + +extension NSPredicate { + /// + /// Matches all notes that are not pending deletion. + /// + static var allNotes: NSPredicate { + NSPredicate(format: "deleteNeeded == %@", NSNumber(value: false)) + } +} diff --git a/iOCNotes/Views/NotesListView.swift b/iOCNotes/Views/NotesListView.swift new file mode 100644 index 00000000..1fbfdfc4 --- /dev/null +++ b/iOCNotes/Views/NotesListView.swift @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH +// SPDX-License-Identifier: GPL-3.0-or-later + +import CoreData +import SwiftUI + +/// +/// SwiftUI list of notes, either grouped by category into collapsible sections or sorted by modification date. +/// +struct NotesListView: View { + @Bindable var model: NotesListModel + + @State private var noteToRename: NSManagedObjectID? + @State private var renameText = "" + @State private var exporter: NoteExporter? + + private var canRename: Bool { + isNextcloud() && KeychainHelper.notesApiVersion != Router.defaultApiVersion + } + + var body: some View { + Group { + if model.sections.isEmpty { + ContentUnavailableView( + String(localized: "No Notes", comment: "Shown when there are no notes to display"), + systemImage: "note.text" + ) + } else { + notesList + } + } + .alert( + String(localized: "Note Title", comment: "Title of alert to change title"), + isPresented: Binding(get: { noteToRename != nil }, set: { if !$0 { noteToRename = nil } }) + ) { + TextField(String(localized: "Note Title", comment: "Title of alert to change title"), text: $renameText) + Button(String(localized: "Cancel", comment: "Caption of Cancel button"), role: .cancel) { + // Dismisses the rename alert without applying changes. + } + Button(String(localized: "Rename", comment: "Caption of Rename button")) { commitRename() } + } message: { + Text(String(localized: "Rename the note", comment: "Message of alert to change title")) + } + } + + private var notesList: some View { + List { + ForEach(model.sections) { section in + if section.hasHeader { + Section(isExpanded: expansionBinding(for: section.title)) { + rows(for: section) + } header: { + Label(section.title, systemImage: "folder") + } + } else { + rows(for: section) + } + } + } + .listStyle(.insetGrouped) + .refreshable { + await NoteSessionManager.shared.sync() + } + .dropDestination(for: String.self) { items, _ in + let contents = items.filter { !$0.isEmpty } + for content in contents { + NoteSessionManager.shared.add(content: content, category: "") + } + return !contents.isEmpty + } + } + + private func rows(for section: NoteListSection) -> some View { + ForEach(section.rows) { row in + Button { + openEditor(for: row) + } label: { + NoteRowView(row: row) + } + .buttonStyle(.plain) + .contextMenu { + contextMenu(for: row) + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + delete(row) + } label: { + Label(String(localized: "Delete", comment: "Action to delete a note"), systemImage: "trash") + } + } + } + } + + @ViewBuilder + private func contextMenu(for row: NoteListRow) -> some View { + if canRename { + Button { + renameText = row.title + noteToRename = row.id + } label: { + Label(String(localized: "Rename…", comment: "Action to change title of a note"), systemImage: "square.and.pencil") + } + } + + if isNextcloud() { + Button { + openCategories(for: row) + } label: { + Label(String(localized: "Category…", comment: "Action to change category of a note"), systemImage: "folder") + } + } + + Button { + share(row) + } label: { + Label(String(localized: "Share", comment: "Action to share a note"), systemImage: "square.and.arrow.up") + } + + Button(role: .destructive) { + delete(row) + } label: { + Label(String(localized: "Delete", comment: "Action to delete a note"), systemImage: "trash") + } + } + + private func expansionBinding(for title: String) -> Binding { + Binding( + get: { model.isExpanded(title) }, + set: { model.setExpanded($0, for: title) } + ) + } + + private func openEditor(for row: NoteListRow) { + guard let note = model.note(for: row.id) else { return } + NotesPresenter.openEditor(for: note, isNewNote: false) + } + + private func openCategories(for row: NoteListRow) { + guard let note = model.note(for: row.id) else { return } + NotesPresenter.openCategories(for: note, categories: Note.categories() ?? []) + } + + private func share(_ row: NoteListRow) { + guard let note = model.note(for: row.id) else { return } + NotesPresenter.share(note: note, exporter: &exporter) + } + + private func delete(_ row: NoteListRow) { + guard let note = model.note(for: row.id) else { return } + NoteSessionManager.shared.delete(note: note) + } + + private func commitRename() { + guard let noteID = noteToRename, + let note = model.note(for: noteID) else { return } + defer { noteToRename = nil } + + let newName = renameText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !newName.isEmpty, newName != note.title else { return } + + note.title = newName + NoteSessionManager.shared.update(note: note, completion: nil) + } +} diff --git a/iOCNotes/Views/NotesPresenter.swift b/iOCNotes/Views/NotesPresenter.swift new file mode 100644 index 00000000..ebfa3493 --- /dev/null +++ b/iOCNotes/Views/NotesPresenter.swift @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH +// SPDX-License-Identifier: GPL-3.0-or-later + +import NextcloudKit +import UIKit + +/// +/// Bridges the SwiftUI notes list to the remaining UIKit screens (note editor, category picker and share sheet). +/// +/// These screens are presented modally from the current top view controller, matching the behaviour the storyboard +/// segues provided before the list was rewritten in SwiftUI. +/// +enum NotesPresenter { + /// + /// Open the given note, using the server's collaborative text editor when direct editing is available and the + /// built-in editor otherwise. + /// + @discardableResult + static func openEditor(for note: Note, isNewNote: Bool) -> Bool { + if isDirectEditingAvailable { + return openInDirectEditing(note: note) + } + + let storyboard = UIStoryboard(name: "Main_iPhone", bundle: nil) + guard let navigationController = storyboard.instantiateViewController(withIdentifier: "Editor") as? UINavigationController, + let editor = navigationController.topViewController as? EditorViewController, + let presenter = UIApplication.topViewController() else { + return false + } + + editor.note = note + editor.isNewNote = isNewNote + navigationController.modalPresentationStyle = .fullScreen + presenter.present(navigationController, animated: true) + return true + } + + /// + /// Present the category picker for the given note. + /// + static func openCategories(for note: Note, categories: [String]) { + let storyboard = UIStoryboard(name: "Categories", bundle: Bundle.main) + guard let navigationController = storyboard.instantiateViewController(withIdentifier: "CategoryNavigationController") as? UINavigationController, + let categoryController = navigationController.topViewController as? CategoryTableViewController, + let presenter = UIApplication.topViewController() else { + return + } + + categoryController.categories = categories + categoryController.note = note + presenter.present(navigationController, animated: true) + } + + /// + /// Present the share sheet for the given note. + /// + static func share(note: Note, exporter: inout NoteExporter?) { + guard !note.content.isEmpty, + let presenter = UIApplication.topViewController() else { + return + } + + let bounds = presenter.view.bounds + let anchor = CGRect(x: bounds.midX, y: bounds.midY, width: 1, height: 1) + let noteExporter = NoteExporter(title: note.title, text: note.content, viewController: presenter, from: anchor, in: presenter.view) + exporter = noteExporter + noteExporter.showMenu() + } + + /// + /// Whether notes should open in the server's collaborative text editor instead of the built-in one. + /// + private static var isDirectEditingAvailable: Bool { + guard KeychainHelper.internalEditor == false, + KeychainHelper.directEditing, + KeychainHelper.directEditingSupportsFileId else { + return false + } + + let reachability = AppDelegate.shared.networkReachability + return reachability == .reachableCellular || reachability == .reachableEthernetOrWiFi + } + + private static func openInDirectEditing(note: Note) -> Bool { + guard let account = KeychainHelper.account, + UIApplication.topViewController() != nil else { + return false + } + + NextcloudKit.shared.textOpenFile(fileNamePath: KeychainHelper.notesPath, fileId: String(note.id), editor: "text", account: account) { _, url, _, error in + DispatchQueue.main.async { + guard let presenter = UIApplication.topViewController() else { return } + + if error == .success, + let url, + let viewController = UIStoryboard(name: "NCViewerNextcloudText", bundle: nil).instantiateInitialViewController() as? NCViewerNextcloudText { + viewController.editor = "text" + viewController.link = url + viewController.fileName = note.title + viewController.modalPresentationStyle = .fullScreen + presenter.present(viewController, animated: true) + } else { + let title = NSLocalizedString("Error", comment: "Title of an error alert") + let messageFormat = NSLocalizedString("Cannot open file for direct editing: %@", comment: "Direct editing failure followed by the underlying error") + let alert = UIAlertController(title: title, message: String(format: messageFormat, error.localizedDescription), preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Default action"), style: .default)) + presenter.present(alert, animated: true) + } + } + } + + return true + } +} diff --git a/iOCNotes/Views/NotesView.swift b/iOCNotes/Views/NotesView.swift index 9e31c354..5db5ab8d 100644 --- a/iOCNotes/Views/NotesView.swift +++ b/iOCNotes/Views/NotesView.swift @@ -8,18 +8,23 @@ import SwiftUI /// Top-level view for the notes navigation. /// struct NotesView: View { - @State private var addNote = false + @State private var model = NotesListModel() @State private var searchText = "" var body: some View { - NotesTableViewControllerRepresentable(addNote: $addNote, searchText: $searchText) - .ignoresSafeArea(.all) + NotesListView(model: model) .searchable(text: $searchText) + .onChange(of: searchText) { _, newValue in + model.search(for: newValue) + } .toolbar { - Button { - addNote = true - } label: { - Image(systemName: "plus") + ToolbarItemGroup(placement: .topBarTrailing) { + sortMenu + Button { + addNote() + } label: { + Image(systemName: "plus") + } } } .toolbarTitleDisplayMode(.inline) @@ -27,6 +32,32 @@ struct NotesView: View { .toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(.visible, for: .tabBar) } + + private var sortMenu: some View { + Menu { + Picker( + String(localized: "Sorting", comment: "Menu heading for note list sorting options"), + selection: Binding(get: { model.groupByCategory }, set: { model.setGroupByCategory($0) }) + ) { + Label(String(localized: "Group by category", comment: "Sorting option grouping notes into their categories"), systemImage: "folder") + .tag(true) + Label(String(localized: "Sort by most recent", comment: "Sorting option ordering notes by modification date"), systemImage: "clock") + .tag(false) + } + .pickerStyle(.inline) + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + } + } + + private func addNote() { + NoteSessionManager.shared.add(content: "", category: "") { note in + guard let note else { return } + DispatchQueue.main.async { + NotesPresenter.openEditor(for: note, isNewNote: true) + } + } + } } #Preview { From 88cc4bddd075f509cfb57e66174b17cf234bcb63 Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Wed, 8 Jul 2026 17:08:01 +0200 Subject: [PATCH 2/6] feat: add favorites management to the notes list Add a favorite toggle via leading swipe action and context menu, and sort favorites to the top within the current sort mode. Closes #52 Signed-off-by: Julius Knorr Assisted-by: ClaudeCode:claude-opus-4-8 --- iOCNotes/Views/NotesListModel.swift | 3 +++ iOCNotes/Views/NotesListView.swift | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/iOCNotes/Views/NotesListModel.swift b/iOCNotes/Views/NotesListModel.swift index eb1ed96a..66cc26a8 100644 --- a/iOCNotes/Views/NotesListModel.swift +++ b/iOCNotes/Views/NotesListModel.swift @@ -122,14 +122,17 @@ final class NotesListModel: NSObject, Logging, NSFetchedResultsControllerDelegat request.predicate = predicate(for: searchText) if groupByCategory { + // "category" must stay first so it matches the section key path; favorites float to the top within each category. request.sortDescriptors = [ NSSortDescriptor(key: "category", ascending: true), + NSSortDescriptor(key: "favorite", ascending: false), NSSortDescriptor(key: "modified", ascending: false), NSSortDescriptor(key: "id", ascending: true), NSSortDescriptor(key: "guid", ascending: true) ] } else { request.sortDescriptors = [ + NSSortDescriptor(key: "favorite", ascending: false), NSSortDescriptor(key: "modified", ascending: false), NSSortDescriptor(key: "id", ascending: true), NSSortDescriptor(key: "guid", ascending: true) diff --git a/iOCNotes/Views/NotesListView.swift b/iOCNotes/Views/NotesListView.swift index 1fbfdfc4..c9f378d4 100644 --- a/iOCNotes/Views/NotesListView.swift +++ b/iOCNotes/Views/NotesListView.swift @@ -81,6 +81,14 @@ struct NotesListView: View { .contextMenu { contextMenu(for: row) } + .swipeActions(edge: .leading) { + Button { + toggleFavorite(row) + } label: { + favoriteLabel(isFavorite: row.favorite) + } + .tint(.yellow) + } .swipeActions(edge: .trailing) { Button(role: .destructive) { delete(row) @@ -110,6 +118,12 @@ struct NotesListView: View { } } + Button { + toggleFavorite(row) + } label: { + favoriteLabel(isFavorite: row.favorite) + } + Button { share(row) } label: { @@ -150,6 +164,21 @@ struct NotesListView: View { NoteSessionManager.shared.delete(note: note) } + @ViewBuilder + private func favoriteLabel(isFavorite: Bool) -> some View { + if isFavorite { + Label(String(localized: "Remove from Favorites", comment: "Action to unmark a note as favorite"), systemImage: "star.slash") + } else { + Label(String(localized: "Favorite", comment: "Action to mark a note as favorite"), systemImage: "star") + } + } + + private func toggleFavorite(_ row: NoteListRow) { + guard let note = model.note(for: row.id) else { return } + note.favorite.toggle() + NoteSessionManager.shared.update(note: note, completion: nil) + } + private func commitRename() { guard let noteID = noteToRename, let note = model.note(for: noteID) else { return } From dc790d849850f944bfd1126d075b16fed5e4e3a0 Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Thu, 9 Jul 2026 22:41:05 +0200 Subject: [PATCH 3/6] perf: keep modification date when toggling favorite Toggling favorite went through the generic update path, which stamped the note with the current time. In a date-sorted list that made the row jump and re-render a second time after the sync round-trip. Pass updateModified: false so a favorite toggle preserves the existing modification date. Signed-off-by: Julius Knorr Assisted-by: ClaudeCode:claude-opus-4-8 --- Networking/NoteSessionManager.swift | 11 +++++++---- iOCNotes/Views/NotesListView.swift | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Networking/NoteSessionManager.swift b/Networking/NoteSessionManager.swift index 60ba6e6f..5878c194 100644 --- a/Networking/NoteSessionManager.swift +++ b/Networking/NoteSessionManager.swift @@ -515,13 +515,13 @@ class NoteSessionManager { } } - func update(note: NoteProtocol, completion: SyncCompletionBlock? = nil) { + func update(note: NoteProtocol, updateModified: Bool = true, completion: SyncCompletionBlock? = nil) { logger.notice("Updating note...") var incoming = note incoming.updateNeeded = true if NoteSessionManager.isOnline { - updateOnServer(incoming) { [weak self] result in + updateOnServer(incoming, updateModified: updateModified) { [weak self] result in switch result { case .success( _): completion?() @@ -538,11 +538,14 @@ class NoteSessionManager { } } - fileprivate func updateOnServer(_ note: NoteProtocol, handler: @escaping SyncHandler) { + fileprivate func updateOnServer(_ note: NoteProtocol, updateModified: Bool = true, handler: @escaping SyncHandler) { + // Metadata-only changes (e.g. toggling favorite) keep the existing modification date so the note does + // not jump to the top of a date-sorted list and the row is not re-sorted a second time after the sync. + let modified = updateModified ? Date().timeIntervalSince1970 : note.modified let parameters: Parameters = ["title": note.title as Any, "content": note.content as Any, "category": note.category as Any, - "modified": Date().timeIntervalSince1970 as Any, + "modified": modified as Any, "favorite": note.favorite] let router = Router.updateNote(id: Int(note.id), paramters: parameters) session diff --git a/iOCNotes/Views/NotesListView.swift b/iOCNotes/Views/NotesListView.swift index c9f378d4..61cf0076 100644 --- a/iOCNotes/Views/NotesListView.swift +++ b/iOCNotes/Views/NotesListView.swift @@ -176,7 +176,7 @@ struct NotesListView: View { private func toggleFavorite(_ row: NoteListRow) { guard let note = model.note(for: row.id) else { return } note.favorite.toggle() - NoteSessionManager.shared.update(note: note, completion: nil) + NoteSessionManager.shared.update(note: note, updateModified: false, completion: nil) } private func commitRename() { From 92ca7dfe58324a4ba6db9ad4914218c1dbae1cb6 Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Mon, 13 Jul 2026 12:34:59 +0200 Subject: [PATCH 4/6] fix: replace reordered favorite rows Signed-off-by: Julius Knorr Assisted-by: Codex:GPT-5 --- iOCNotes/Views/NotesListModel.swift | 34 ++++++++++++++++++++++++++--- iOCNotes/Views/NotesListView.swift | 13 +++++------ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/iOCNotes/Views/NotesListModel.swift b/iOCNotes/Views/NotesListModel.swift index 66cc26a8..2aba7b48 100644 --- a/iOCNotes/Views/NotesListModel.swift +++ b/iOCNotes/Views/NotesListModel.swift @@ -10,16 +10,26 @@ import SwiftUI /// Immutable values needed to identify, render and act on a note list row. /// struct NoteListRow: Equatable, Identifiable { + /// + /// Identity used by the native list, which must replace a swiped row when favorite sorting moves it. + /// + struct PresentationID: Hashable { + let noteID: NSManagedObjectID + let favorite: Bool + } + private static let snippetLength = 512 - let id: NSManagedObjectID + let noteID: NSManagedObjectID let title: String let snippet: String let modified: Double let favorite: Bool + var id: PresentationID { PresentationID(noteID: noteID, favorite: favorite) } + init(note: Note) { - id = note.objectID + noteID = note.objectID title = note.title snippet = Self.snippet(from: note.content) modified = note.modified @@ -63,6 +73,7 @@ final class NotesListModel: NSObject, Logging, NSFetchedResultsControllerDelegat @ObservationIgnored private var fetchedResultsController: NSFetchedResultsController? @ObservationIgnored private var searchText = "" + @ObservationIgnored private var pendingFavoriteRowReplacement = false private var collapsedTitles: Set private(set) var groupByCategory: Bool @@ -185,11 +196,28 @@ final class NotesListModel: NSObject, Logging, NSFetchedResultsControllerDelegat try? NotesData.mainThreadContext.existingObject(with: id) as? Note } + func toggleFavorite(for id: NSManagedObjectID) -> Note? { + guard let note = note(for: id) else { return nil } + pendingFavoriteRowReplacement = true + note.favorite.toggle() + return note + } + // MARK: - NSFetchedResultsControllerDelegate @objc func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - rebuildSections() + guard pendingFavoriteRowReplacement else { + rebuildSections() + return + } + + pendingFavoriteRowReplacement = false + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + rebuildSections() + } } } diff --git a/iOCNotes/Views/NotesListView.swift b/iOCNotes/Views/NotesListView.swift index 61cf0076..04ed0451 100644 --- a/iOCNotes/Views/NotesListView.swift +++ b/iOCNotes/Views/NotesListView.swift @@ -104,7 +104,7 @@ struct NotesListView: View { if canRename { Button { renameText = row.title - noteToRename = row.id + noteToRename = row.noteID } label: { Label(String(localized: "Rename…", comment: "Action to change title of a note"), systemImage: "square.and.pencil") } @@ -145,22 +145,22 @@ struct NotesListView: View { } private func openEditor(for row: NoteListRow) { - guard let note = model.note(for: row.id) else { return } + guard let note = model.note(for: row.noteID) else { return } NotesPresenter.openEditor(for: note, isNewNote: false) } private func openCategories(for row: NoteListRow) { - guard let note = model.note(for: row.id) else { return } + guard let note = model.note(for: row.noteID) else { return } NotesPresenter.openCategories(for: note, categories: Note.categories() ?? []) } private func share(_ row: NoteListRow) { - guard let note = model.note(for: row.id) else { return } + guard let note = model.note(for: row.noteID) else { return } NotesPresenter.share(note: note, exporter: &exporter) } private func delete(_ row: NoteListRow) { - guard let note = model.note(for: row.id) else { return } + guard let note = model.note(for: row.noteID) else { return } NoteSessionManager.shared.delete(note: note) } @@ -174,8 +174,7 @@ struct NotesListView: View { } private func toggleFavorite(_ row: NoteListRow) { - guard let note = model.note(for: row.id) else { return } - note.favorite.toggle() + guard let note = model.toggleFavorite(for: row.noteID) else { return } NoteSessionManager.shared.update(note: note, updateModified: false, completion: nil) } From 900283ad7c908ff7759c92d6e3a652ef181fc85a Mon Sep 17 00:00:00 2001 From: Julius Knorr Date: Wed, 15 Jul 2026 15:18:30 +0200 Subject: [PATCH 5/6] fix: restore local editor storyboard scene Assisted-by: Codex:GPT-5 Signed-off-by: Julius Knorr --- Source/Base.lproj/Main_iPhone.storyboard | 93 ++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/Source/Base.lproj/Main_iPhone.storyboard b/Source/Base.lproj/Main_iPhone.storyboard index 2465fd93..241d2155 100644 --- a/Source/Base.lproj/Main_iPhone.storyboard +++ b/Source/Base.lproj/Main_iPhone.storyboard @@ -28,6 +28,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 8566290810428f3fc8abcf5ca5960ef5b078bfd5 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 24 Jul 2026 12:54:06 +0200 Subject: [PATCH 6/6] Fix NCKit build Signed-off-by: Milen Pivchev --- Source/AppDelegate.swift | 5 +- iOCNotes.xcodeproj/project.pbxproj | 36 ++------ .../xcshareddata/swiftpm/Package.resolved | 36 ++------ iOCNotes/Store.swift | 91 +------------------ iOCNotes/Views/ContentView.swift | 6 +- iOCNotes/Views/SettingsView.swift | 2 +- 6 files changed, 24 insertions(+), 152 deletions(-) diff --git a/Source/AppDelegate.swift b/Source/AppDelegate.swift index 46f18b8f..9314aba0 100644 --- a/Source/AppDelegate.swift +++ b/Source/AppDelegate.swift @@ -18,7 +18,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { /// /// Updated by being the `NextcloudKitDelegate`. /// - var networkReachability: NKCommon.TypeReachability? + var networkReachability: NKTypeReachability? static var shared: AppDelegate { return UIApplication.shared.delegate as! AppDelegate @@ -38,7 +38,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { userId: account.userId, password: account.password, userAgent: userAgent, - nextcloudVersion: account.serverVersion.major, groupIdentifier: NCBrandOptions.shared.capabilitiesGroup ) } @@ -203,7 +202,7 @@ extension AppDelegate: NextcloudKitDelegate { } } - public func networkReachabilityObserver(_ typeReachability: NKCommon.TypeReachability) { + public func networkReachabilityObserver(_ typeReachability: NKTypeReachability) { self.networkReachability = typeReachability } } diff --git a/iOCNotes.xcodeproj/project.pbxproj b/iOCNotes.xcodeproj/project.pbxproj index dcd1ba3d..1055d009 100644 --- a/iOCNotes.xcodeproj/project.pbxproj +++ b/iOCNotes.xcodeproj/project.pbxproj @@ -9,8 +9,6 @@ /* Begin PBXBuildFile section */ AA182D002DDCD6520058C246 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = AA182CFF2DDCD6520058C246 /* CodeScanner */; }; AA30543C2E12B06F00D33159 /* HTTPHeader+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA30543B2E12B06700D33159 /* HTTPHeader+Extensions.swift */; }; - AA83B8AA2DEF080700F35F3E /* SwiftNextcloudUI in Frameworks */ = {isa = PBXBuildFile; productRef = AA83B8A92DEF080700F35F3E /* SwiftNextcloudUI */; }; - AAA900B42DE6F37400D3EF0F /* SwiftNextcloudUI in Frameworks */ = {isa = PBXBuildFile; productRef = AAA900B32DE6F37400D3EF0F /* SwiftNextcloudUI */; }; AABC95572DFC717F0023790D /* UIApplication+topViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABC95562DFC717A0023790D /* UIApplication+topViewController.swift */; }; AAC79FE82E279184006D6845 /* MarkdownTextStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC79FE72E279184006D6845 /* MarkdownTextStorage.swift */; }; BD653F88231C43FA00D59A88 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD653F87231C43FA00D59A88 /* Constants.swift */; }; @@ -72,6 +70,7 @@ F3E3C8DC2C6B7DA600A80504 /* UIColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E3C8DB2C6B7DA600A80504 /* UIColor+Extension.swift */; }; F3E3D5CA2F9F6A1C007FC9F0 /* Note+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E3D5C82F9F6A1C007FC9F0 /* Note+CoreDataClass.swift */; }; F3E3D5CB2F9F6A1C007FC9F0 /* Note+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E3D5C92F9F6A1C007FC9F0 /* Note+CoreDataProperties.swift */; }; + F3F1E30D30136E1A009CB910 /* NextcloudKitUI in Frameworks */ = {isa = PBXBuildFile; productRef = F3F1E30C30136E1A009CB910 /* NextcloudKitUI */; }; F3F734512C6FA550007C8C0B /* Notes.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F3F7344A2C6FA550007C8C0B /* Notes.xcdatamodeld */; }; F3F734522C6FA550007C8C0B /* NotesData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3F7344B2C6FA550007C8C0B /* NotesData.swift */; }; F3F734582C6FAC17007C8C0B /* Theming.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3F734572C6FAC17007C8C0B /* Theming.swift */; }; @@ -225,7 +224,6 @@ buildActionMask = 2147483647; files = ( D0C876DF248F18700052EA63 /* Version in Frameworks */, - AAA900B42DE6F37400D3EF0F /* SwiftNextcloudUI in Frameworks */, D0595C7C238632610004C4E8 /* KeychainAccess in Frameworks */, D02D15CC2905D756006ADCBB /* OpenSSL in Frameworks */, D06CDA60238F6EC300CF90B0 /* Alamofire in Frameworks */, @@ -233,9 +231,9 @@ F77B56FF2993DE6300A23E76 /* NextcloudKit in Frameworks */, D017D4241D3B03BE00B21443 /* WebKit.framework in Frameworks */, D0CFE5AD1890659600165839 /* QuartzCore.framework in Frameworks */, - AA83B8AA2DEF080700F35F3E /* SwiftNextcloudUI in Frameworks */, F75D7B1229C8687C00ABD392 /* JGProgressHUD in Frameworks */, D0CAFF162533E89900C47FA0 /* PKHUD in Frameworks */, + F3F1E30D30136E1A009CB910 /* NextcloudKitUI in Frameworks */, D0CFE53B1888A7BD00165839 /* UIKit.framework in Frameworks */, D0CFE5371888A7BD00165839 /* Foundation.framework in Frameworks */, D0CAFF092533E7FB00C47FA0 /* SwiftMessages in Frameworks */, @@ -524,8 +522,7 @@ F77B56FE2993DE6300A23E76 /* NextcloudKit */, F75D7B1129C8687C00ABD392 /* JGProgressHUD */, AA182CFF2DDCD6520058C246 /* CodeScanner */, - AAA900B32DE6F37400D3EF0F /* SwiftNextcloudUI */, - AA83B8A92DEF080700F35F3E /* SwiftNextcloudUI */, + F3F1E30C30136E1A009CB910 /* NextcloudKitUI */, ); productName = iOCNotes; productReference = D0CFE5331888A7BD00165839 /* Nextcloud Notes.app */; @@ -599,7 +596,6 @@ F77B56FD2993DE6300A23E76 /* XCRemoteSwiftPackageReference "NextcloudKit" */, F75D7B1029C8687C00ABD392 /* XCRemoteSwiftPackageReference "JGProgressHUD" */, AA182CFE2DDCD6520058C246 /* XCRemoteSwiftPackageReference "CodeScanner" */, - AA83B8A82DEF080700F35F3E /* XCRemoteSwiftPackageReference "swiftnextcloudui" */, ); productRefGroup = D0CFE5341888A7BD00165839 /* Products */; projectDirPath = ""; @@ -1092,14 +1088,6 @@ minimumVersion = 2.5.2; }; }; - AA83B8A82DEF080700F35F3E /* XCRemoteSwiftPackageReference "swiftnextcloudui" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/nextcloud/swiftnextcloudui"; - requirement = { - branch = main; - kind = branch; - }; - }; D02D15CA2905D756006ADCBB /* XCRemoteSwiftPackageReference "OpenSSL" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/krzyzanowskim/OpenSSL"; @@ -1168,8 +1156,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/nextcloud/NextcloudKit"; requirement = { - kind = exactVersion; - version = 6.1.0; + kind = upToNextMajorVersion; + minimumVersion = 7.4.1; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -1180,15 +1168,6 @@ package = AA182CFE2DDCD6520058C246 /* XCRemoteSwiftPackageReference "CodeScanner" */; productName = CodeScanner; }; - AA83B8A92DEF080700F35F3E /* SwiftNextcloudUI */ = { - isa = XCSwiftPackageProductDependency; - package = AA83B8A82DEF080700F35F3E /* XCRemoteSwiftPackageReference "swiftnextcloudui" */; - productName = SwiftNextcloudUI; - }; - AAA900B32DE6F37400D3EF0F /* SwiftNextcloudUI */ = { - isa = XCSwiftPackageProductDependency; - productName = SwiftNextcloudUI; - }; D02D15CB2905D756006ADCBB /* OpenSSL */ = { isa = XCSwiftPackageProductDependency; package = D02D15CA2905D756006ADCBB /* XCRemoteSwiftPackageReference "OpenSSL" */; @@ -1224,6 +1203,11 @@ package = D0E60F222777DD38009CF78F /* XCRemoteSwiftPackageReference "swift-markdown" */; productName = Markdown; }; + F3F1E30C30136E1A009CB910 /* NextcloudKitUI */ = { + isa = XCSwiftPackageProductDependency; + package = F77B56FD2993DE6300A23E76 /* XCRemoteSwiftPackageReference "NextcloudKit" */; + productName = NextcloudKitUI; + }; F75D7B1129C8687C00ABD392 /* JGProgressHUD */ = { isa = XCSwiftPackageProductDependency; package = F75D7B1029C8687C00ABD392 /* XCRemoteSwiftPackageReference "JGProgressHUD" */; diff --git a/iOCNotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iOCNotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7a6ca25b..073c5a86 100644 --- a/iOCNotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iOCNotes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "bdbe1470ac762e49baed317575243d81ed9f32538fd0bdb49c3638f738dc335c", + "originHash" : "362600762584dfcdd7a2dce6bf3ff2289adda012d9991e48e76a89167b3e0447", "pins" : [ { "identity" : "alamofire", "kind" : "remoteSourceControl", "location" : "https://github.com/Alamofire/Alamofire", "state" : { - "revision" : "e938f8c66708e7352fc7e3512647fa54255b267a", - "version" : "5.11.2" + "revision" : "7595cbcf59809f9977c5f6378500de2ad73b7ddb", + "version" : "5.12.0" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/nextcloud/NextcloudKit", "state" : { - "revision" : "33b8c019831b9175229ae37b1c8ba8c71e5dc763", - "version" : "6.1.0" + "revision" : "6d21f1258d1fb67b510c3b66129e73d86114fe14", + "version" : "7.4.1" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/krzyzanowskim/OpenSSL", "state" : { - "revision" : "e7d34385da795aaaeb2212e38c7401e5c333f792", - "version" : "3.6.1" + "revision" : "fdc9231384f37f053dffe058fd6dfc6c5072dae5", + "version" : "3.6.2000" } }, { @@ -70,7 +70,7 @@ "location" : "https://github.com/swiftlang/swift-cmark.git", "state" : { "branch" : "gfm", - "revision" : "924936d0427cb25a61169739a7660230bffa6ea6" + "revision" : "0101bf2c6ff6a218f93150f340fe5ccf76d9f3aa" } }, { @@ -79,16 +79,7 @@ "location" : "https://github.com/swiftlang/swift-markdown", "state" : { "branch" : "main", - "revision" : "55d66d9a9e8d4fd3f48d111b0d437e82fe451903" - } - }, - { - "identity" : "swiftlintplugins", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SimplyDanny/SwiftLintPlugins", - "state" : { - "revision" : "8a4640d14777685ba8f14e832373160498fbab92", - "version" : "0.63.2" + "revision" : "27b7fc1a19068bcea3d2072db0ce86360d1400ed" } }, { @@ -100,15 +91,6 @@ "version" : "10.0.2" } }, - { - "identity" : "swiftnextcloudui", - "kind" : "remoteSourceControl", - "location" : "https://github.com/nextcloud/swiftnextcloudui", - "state" : { - "branch" : "main", - "revision" : "a3254d493626319afe0e720d195ff983b95cb1e4" - } - }, { "identity" : "swiftyjson", "kind" : "remoteSourceControl", diff --git a/iOCNotes/Store.swift b/iOCNotes/Store.swift index d4851a45..96310894 100644 --- a/iOCNotes/Store.swift +++ b/iOCNotes/Store.swift @@ -4,7 +4,6 @@ import Foundation import NextcloudKit -import SwiftNextcloudUI import SwiftyJSON import UIKit @@ -12,13 +11,6 @@ import UIKit final class Store: Logging, Storing { let logger = makeLogger() - /// - /// Required for `ServerAddressViewDelegate` conformance. - /// - /// The currently active login flow polling task. - /// - var pollingTask: Task? - /// /// This singleton is necessary to conveniently expose the same environment object used in SwiftUI to the already existing UIKit code. /// @@ -79,7 +71,7 @@ final class Store: Logging, Storing { "version" ] - let (_, data, error) = await NextcloudKit.shared.getCapabilitiesAsync(account: account.id) + let (_, _, data, error) = await NextcloudKit.shared.getCapabilitiesAsync(account: account.id) guard error == .success else { logger.error("Failed to fetch capabilities: \(error.localizedDescription, privacy: .public)") @@ -102,8 +94,6 @@ final class Store: Logging, Storing { KeychainHelper.serverMajorVersion = capabilities[serverVersionKeyPath]["major"].int ?? 0 KeychainHelper.serverMinorVersion = capabilities[serverVersionKeyPath]["minor"].int ?? 0 KeychainHelper.serverMicroVersion = capabilities[serverVersionKeyPath]["micro"].int ?? 0 - - NextcloudKit.shared.updateSession(account: account.id, nextcloudVersion: KeychainHelper.serverMajorVersion) } // swiftlint:enable function_body_length @@ -126,7 +116,6 @@ final class Store: Logging, Storing { userId: account.userId, password: account.password, userAgent: userAgent, - nextcloudVersion: account.serverVersion.major, groupIdentifier: NCBrandOptions.shared.capabilitiesGroup ) @@ -288,82 +277,4 @@ final class Store: Logging, Storing { KeychainHelper.syncOnStart = newValue } } - - private func getResponse(endpoint: URL, token: String, options: NKRequestOptions) async -> (url: String, user: String, appPassword: String)? { - logger.debug("Getting login flow status...") - - return await withCheckedContinuation { continuation in - NextcloudKit.shared.getLoginFlowV2Poll(token: token, endpoint: endpoint.absoluteString, options: options) { [self] server, loginName, appPassword, _, error in - if error == .success, let urlBase = server, let user = loginName, let appPassword { - logger.debug("Successfully got login flow status (server: \(urlBase), user: \(user), password: \(appPassword)).") - continuation.resume(returning: (urlBase, user, appPassword)) - } else { - logger.debug("Failed to get login flow status.") - continuation.resume(returning: nil) - } - } - } - } - - func beginPolling(at url: URL) async throws -> URL { - logger.debug("Beginning polling at \(url.absoluteString)") - - let (_, serverInfoResult) = await NextcloudKit.shared.getServerStatusAsync(serverUrl: url.absoluteString) - - switch serverInfoResult { - case .success: - let loginOptions = NKRequestOptions(customUserAgent: userAgent) - let (endpoint, loginAddress, token) = try await NextcloudKit.shared.getLoginFlowV2(serverUrl: url.absoluteString, options: loginOptions) - let options = NKRequestOptions(customUserAgent: userAgent) - var grantValues: (url: String, user: String, appPassword: String)? - - logger.debug("Received login address \"\(loginAddress)\" with polling endpoint \"\(endpoint)\" and token \"\(token)\".") - - if let pollingTask { - logger.debug("Cancelling previous polling task before starting a new one.") - pollingTask.cancel() - self.pollingTask = nil - } - - self.pollingTask = Task { @MainActor in - defer { - self.pollingTask = nil - } - - repeat { - try Task.checkCancellation() - grantValues = await getResponse(endpoint: endpoint, token: token, options: options) - try await Task.sleep(for: .seconds(1)) - } while grantValues == nil - - guard let grantValues else { - return - } - - guard let host = URL(string: grantValues.url) else { - return - } - - addAccount(host: host, name: grantValues.user, password: grantValues.appPassword) - } - - return loginAddress - case .failure(let nKError): - logger.error("Received error as response to server status: \(nKError.errorDescription)") - throw nKError.error - } - } - - func cancelPolling() { - logger.debug("Cancelling polling task.") - - guard let pollingTask else { - logger.error("Attempt to cancel polling task but no task is active!") - return - } - - pollingTask.cancel() - self.pollingTask = nil - logger.debug("Polling task cancelled.") - } } diff --git a/iOCNotes/Views/ContentView.swift b/iOCNotes/Views/ContentView.swift index 24396ca3..17beec8a 100644 --- a/iOCNotes/Views/ContentView.swift +++ b/iOCNotes/Views/ContentView.swift @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: 2025 Iva Horn // SPDX-License-Identifier: GPL-3.0-or-later -import SwiftNextcloudUI +import NextcloudKitUI import SwiftUI /// @@ -37,10 +37,6 @@ struct ContentView: View { if store.accounts.isEmpty { ServerAddressView(backgroundColor: .constant(Color.accent), brandImage: Image("BrandLogo"), sharedAccounts: sharedAccounts, userAgent: userAgent) { host, name, password in store.addAccount(host: host, name: name, password: password) - } beginPolling: { url, _ in - try await store.beginPolling(at: url) - } cancelPolling: { - store.cancelPolling() } .onAppear { // The store must update its list of shared accounts when the login user interface is about to appear. diff --git a/iOCNotes/Views/SettingsView.swift b/iOCNotes/Views/SettingsView.swift index dd67f792..ad6edd3a 100644 --- a/iOCNotes/Views/SettingsView.swift +++ b/iOCNotes/Views/SettingsView.swift @@ -3,7 +3,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later import NextcloudKit -import SwiftNextcloudUI +import NextcloudKitUI import SwiftUI ///