parent
dcfee339af
commit
533c34391f
@ -0,0 +1,2 @@ |
|||||||
|
Notes is an iOS app to keep notes, synchronized with CloudKit |
||||||
|
Code is written in Swift, and the UI is in SwiftUI. |
||||||
@ -1,101 +0,0 @@ |
|||||||
// |
|
||||||
// AppDelegate.swift |
|
||||||
// Notes |
|
||||||
// |
|
||||||
// Created by Laurent Morvillier on 21/09/2022. |
|
||||||
// |
|
||||||
|
|
||||||
import UIKit |
|
||||||
import CoreData |
|
||||||
|
|
||||||
@main |
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate { |
|
||||||
|
|
||||||
static var shared: AppDelegate { |
|
||||||
return UIApplication.shared.delegate as! AppDelegate |
|
||||||
} |
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { |
|
||||||
|
|
||||||
// if let content = PreferencesStorage.main.getContent(filename: "main.txt") { |
|
||||||
// let note = Note(context: AppDelegate.viewContext) |
|
||||||
// note.content = content |
|
||||||
// AppDelegate.shared.saveContext() |
|
||||||
// print("default note created") |
|
||||||
// } |
|
||||||
|
|
||||||
// Override point for customization after application launch. |
|
||||||
return true |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: UISceneSession Lifecycle |
|
||||||
|
|
||||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { |
|
||||||
// Called when a new scene session is being created. |
|
||||||
// Use this method to select a configuration to create the new scene with. |
|
||||||
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) |
|
||||||
} |
|
||||||
|
|
||||||
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) { |
|
||||||
// Called when the user discards a scene session. |
|
||||||
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. |
|
||||||
// Use this method to release any resources that were specific to the discarded scenes, as they will not return. |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Core Data stack |
|
||||||
|
|
||||||
lazy var persistentContainer: NSPersistentCloudKitContainer = { |
|
||||||
/* |
|
||||||
The persistent container for the application. This implementation |
|
||||||
creates and returns a container, having loaded the store for the |
|
||||||
application to it. This property is optional since there are legitimate |
|
||||||
error conditions that could cause the creation of the store to fail. |
|
||||||
*/ |
|
||||||
let container = NSPersistentCloudKitContainer(name: "Notes") |
|
||||||
container.loadPersistentStores(completionHandler: { (storeDescription, error) in |
|
||||||
|
|
||||||
if let error = error as NSError? { |
|
||||||
// Replace this implementation with code to handle the error appropriately. |
|
||||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. |
|
||||||
|
|
||||||
/* |
|
||||||
Typical reasons for an error here include: |
|
||||||
* The parent directory does not exist, cannot be created, or disallows writing. |
|
||||||
* The persistent store is not accessible, due to permissions or data protection when the device is locked. |
|
||||||
* The device is out of space. |
|
||||||
* The store could not be migrated to the current model version. |
|
||||||
Check the error message to determine what the actual problem was. |
|
||||||
*/ |
|
||||||
fatalError("Unresolved error \(error), \(error.userInfo)") |
|
||||||
} |
|
||||||
}) |
|
||||||
|
|
||||||
container.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy |
|
||||||
// container.viewContext.automaticallyMergesChangesFromParent = true |
|
||||||
|
|
||||||
return container |
|
||||||
}() |
|
||||||
|
|
||||||
// MARK: - Core Data Saving support |
|
||||||
|
|
||||||
static var viewContext: NSManagedObjectContext { |
|
||||||
return self.shared.persistentContainer.viewContext |
|
||||||
} |
|
||||||
|
|
||||||
func saveContext () { |
|
||||||
print("save context...") |
|
||||||
let context = persistentContainer.viewContext |
|
||||||
if context.hasChanges { |
|
||||||
do { |
|
||||||
try context.save() |
|
||||||
} catch { |
|
||||||
// Replace this implementation with code to handle the error appropriately. |
|
||||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. |
|
||||||
let nserror = error as NSError |
|
||||||
fatalError("Unresolved error \(nserror), \(nserror.userInfo)") |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
@ -0,0 +1,25 @@ |
|||||||
|
// |
||||||
|
// AutoSaveManager.swift |
||||||
|
// Notes |
||||||
|
// |
||||||
|
// Created by Claude Code on 13/10/2025. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
import Combine |
||||||
|
|
||||||
|
class AutoSaveManager: ObservableObject { |
||||||
|
private var timer: Timer? |
||||||
|
private let idleTimeBeforeSaving: TimeInterval = 2.0 |
||||||
|
|
||||||
|
func requestSave(action: @escaping () -> Void) { |
||||||
|
timer?.invalidate() |
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: idleTimeBeforeSaving, repeats: false) { _ in |
||||||
|
action() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
deinit { |
||||||
|
timer?.invalidate() |
||||||
|
} |
||||||
|
} |
||||||
@ -1,25 +0,0 @@ |
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?> |
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM"> |
|
||||||
<dependencies> |
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/> |
|
||||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/> |
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> |
|
||||||
</dependencies> |
|
||||||
<scenes> |
|
||||||
<!--View Controller--> |
|
||||||
<scene sceneID="EHf-IW-A2E"> |
|
||||||
<objects> |
|
||||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController"> |
|
||||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3"> |
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/> |
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> |
|
||||||
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/> |
|
||||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/> |
|
||||||
</view> |
|
||||||
</viewController> |
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/> |
|
||||||
</objects> |
|
||||||
<point key="canvasLocation" x="53" y="375"/> |
|
||||||
</scene> |
|
||||||
</scenes> |
|
||||||
</document> |
|
||||||
@ -1,81 +0,0 @@ |
|||||||
<?xml version="1.0" encoding="UTF-8"?> |
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21225" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="TxU-Fl-tNx"> |
|
||||||
<device id="retina6_1" orientation="portrait" appearance="light"/> |
|
||||||
<dependencies> |
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21207"/> |
|
||||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/> |
|
||||||
<capability name="System colors in document resources" minToolsVersion="11.0"/> |
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> |
|
||||||
</dependencies> |
|
||||||
<scenes> |
|
||||||
<!--Navigation Controller--> |
|
||||||
<scene sceneID="hez-bJ-diu"> |
|
||||||
<objects> |
|
||||||
<navigationController id="TxU-Fl-tNx" sceneMemberID="viewController"> |
|
||||||
<navigationBar key="navigationBar" contentMode="scaleToFill" id="WFB-v3-7ji"> |
|
||||||
<rect key="frame" x="0.0" y="48" width="414" height="44"/> |
|
||||||
<autoresizingMask key="autoresizingMask"/> |
|
||||||
</navigationBar> |
|
||||||
<connections> |
|
||||||
<segue destination="bLT-f5-baA" kind="relationship" relationship="rootViewController" id="zSe-W8-vnK"/> |
|
||||||
</connections> |
|
||||||
</navigationController> |
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="It9-d6-eH5" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/> |
|
||||||
</objects> |
|
||||||
<point key="canvasLocation" x="810" y="-664"/> |
|
||||||
</scene> |
|
||||||
<!--Notes Page View Controller--> |
|
||||||
<scene sceneID="mCo-Xm-AqT"> |
|
||||||
<objects> |
|
||||||
<pageViewController autoresizesArchivedViewToFullSize="NO" transitionStyle="scroll" navigationOrientation="horizontal" spineLocation="none" id="bLT-f5-baA" customClass="NotesPageViewController" customModule="Notes" customModuleProvider="target" sceneMemberID="viewController"> |
|
||||||
<navigationItem key="navigationItem" id="1nB-b5-m4A"/> |
|
||||||
</pageViewController> |
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="XzJ-cd-L4C" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/> |
|
||||||
</objects> |
|
||||||
<point key="canvasLocation" x="1594" y="-664"/> |
|
||||||
</scene> |
|
||||||
<!--Note View Controller--> |
|
||||||
<scene sceneID="3me-1s-GmT"> |
|
||||||
<objects> |
|
||||||
<viewController storyboardIdentifier="note" id="NKc-C6-bTZ" customClass="NoteViewController" customModule="Notes" customModuleProvider="target" sceneMemberID="viewController"> |
|
||||||
<view key="view" contentMode="scaleToFill" id="zfQ-Xr-jsC"> |
|
||||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/> |
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> |
|
||||||
<subviews> |
|
||||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="zPN-eq-BkD"> |
|
||||||
<rect key="frame" x="8" y="56" width="398" height="798"/> |
|
||||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/> |
|
||||||
<color key="textColor" systemColor="labelColor"/> |
|
||||||
<fontDescription key="fontDescription" name="DINAlternate-Bold" family="DIN Alternate" pointSize="14"/> |
|
||||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> |
|
||||||
</textView> |
|
||||||
</subviews> |
|
||||||
<viewLayoutGuide key="safeArea" id="Qu6-9S-tel"/> |
|
||||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/> |
|
||||||
<constraints> |
|
||||||
<constraint firstItem="Qu6-9S-tel" firstAttribute="trailing" secondItem="zPN-eq-BkD" secondAttribute="trailing" constant="8" id="9rz-bm-QIs"/> |
|
||||||
<constraint firstItem="Qu6-9S-tel" firstAttribute="bottom" secondItem="zPN-eq-BkD" secondAttribute="bottom" constant="8" id="ElV-mT-Day"/> |
|
||||||
<constraint firstItem="zPN-eq-BkD" firstAttribute="leading" secondItem="Qu6-9S-tel" secondAttribute="leading" constant="8" id="hUX-Ie-RB0"/> |
|
||||||
<constraint firstItem="zPN-eq-BkD" firstAttribute="top" secondItem="Qu6-9S-tel" secondAttribute="top" constant="8" id="jaW-K0-O4W"/> |
|
||||||
</constraints> |
|
||||||
</view> |
|
||||||
<navigationItem key="navigationItem" id="obh-lB-Hmb"/> |
|
||||||
<connections> |
|
||||||
<outlet property="textView" destination="zPN-eq-BkD" id="gC1-fy-xI4"/> |
|
||||||
<outlet property="textViewBottomConstraint" destination="ElV-mT-Day" id="esj-WS-G5P"/> |
|
||||||
</connections> |
|
||||||
</viewController> |
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="BQh-DG-myD" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/> |
|
||||||
</objects> |
|
||||||
<point key="canvasLocation" x="2291" y="-664"/> |
|
||||||
</scene> |
|
||||||
</scenes> |
|
||||||
<resources> |
|
||||||
<systemColor name="labelColor"> |
|
||||||
<color red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> |
|
||||||
</systemColor> |
|
||||||
<systemColor name="systemBackgroundColor"> |
|
||||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> |
|
||||||
</systemColor> |
|
||||||
</resources> |
|
||||||
</document> |
|
||||||
@ -0,0 +1,29 @@ |
|||||||
|
// |
||||||
|
// CloudKitSyncMonitor.swift |
||||||
|
// Notes |
||||||
|
// |
||||||
|
// Created by Claude Code on 13/10/2025. |
||||||
|
// |
||||||
|
|
||||||
|
import Foundation |
||||||
|
import CoreData |
||||||
|
import Combine |
||||||
|
|
||||||
|
class CloudKitSyncMonitor: ObservableObject { |
||||||
|
private var cancellables = Set<AnyCancellable>() |
||||||
|
|
||||||
|
init() { |
||||||
|
NotificationCenter.default.publisher(for: NSPersistentCloudKitContainer.eventChangedNotification) |
||||||
|
.sink { [weak self] notification in |
||||||
|
self?.handleCloudKitEvent(notification) |
||||||
|
} |
||||||
|
.store(in: &cancellables) |
||||||
|
} |
||||||
|
|
||||||
|
private func handleCloudKitEvent(_ notification: Notification) { |
||||||
|
print("CloudKit sync event received...") |
||||||
|
// Handle CloudKit sync events if needed |
||||||
|
// In SwiftUI with @FetchRequest, the UI updates automatically |
||||||
|
// when Core Data changes, so additional handling may not be necessary |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,69 @@ |
|||||||
|
// |
||||||
|
// NoteEditorView.swift |
||||||
|
// Notes |
||||||
|
// |
||||||
|
// Created by Claude Code on 13/10/2025. |
||||||
|
// |
||||||
|
|
||||||
|
import SwiftUI |
||||||
|
import CoreData |
||||||
|
|
||||||
|
struct NoteEditorView: View { |
||||||
|
@ObservedObject var note: Note |
||||||
|
@Environment(\.managedObjectContext) private var viewContext |
||||||
|
@StateObject private var autoSaveManager = AutoSaveManager() |
||||||
|
@FocusState private var isFocused: Bool |
||||||
|
|
||||||
|
var body: some View { |
||||||
|
VStack { |
||||||
|
|
||||||
|
TextEditor(text: Binding( |
||||||
|
get: { note.content ?? "" }, |
||||||
|
set: { newValue in |
||||||
|
note.content = newValue |
||||||
|
note.lastEditDate = Date() |
||||||
|
autoSaveManager.requestSave { |
||||||
|
PersistenceController.shared.save() |
||||||
|
} |
||||||
|
} |
||||||
|
)) |
||||||
|
.font(.system(size: 18, weight: .regular)) |
||||||
|
.foregroundColor(Color(UIColor.label)) |
||||||
|
.padding(8) |
||||||
|
.focused($isFocused) |
||||||
|
} |
||||||
|
.toolbar { |
||||||
|
ToolbarItemGroup(placement: .keyboard) { |
||||||
|
if let date = note.lastEditDate { |
||||||
|
Text("last edit: \(date.formatted())") |
||||||
|
.font(.footnote) |
||||||
|
.foregroundStyle(.secondary) |
||||||
|
.frame(width: 220.0) |
||||||
|
} |
||||||
|
|
||||||
|
Spacer() |
||||||
|
|
||||||
|
Button { |
||||||
|
isFocused = false |
||||||
|
} label: { |
||||||
|
Label("Done", systemImage: "text.badge.checkmark") |
||||||
|
.labelStyle(.iconOnly) |
||||||
|
} |
||||||
|
.buttonStyle(.bordered) |
||||||
|
.tint(.primary) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
struct NoteEditorView_Previews: PreviewProvider { |
||||||
|
static var previews: some View { |
||||||
|
let context = PersistenceController.preview.container.viewContext |
||||||
|
let note = Note(context: context) |
||||||
|
note.content = "Sample note content\n\nThis is a preview." |
||||||
|
note.lastEditDate = Date() |
||||||
|
|
||||||
|
return NoteEditorView(note: note) |
||||||
|
.environment(\.managedObjectContext, context) |
||||||
|
} |
||||||
|
} |
||||||
@ -1,156 +0,0 @@ |
|||||||
// |
|
||||||
// NoteViewController.swift |
|
||||||
// Notes |
|
||||||
// |
|
||||||
// Created by Laurent Morvillier on 04/09/2022. |
|
||||||
// |
|
||||||
|
|
||||||
import Foundation |
|
||||||
import UIKit |
|
||||||
import CoreData |
|
||||||
|
|
||||||
class NoteViewController : UIViewController, UITextViewDelegate { |
|
||||||
|
|
||||||
var index = 0 |
|
||||||
|
|
||||||
var note: Note? = nil |
|
||||||
|
|
||||||
@IBOutlet weak var textView: UITextView! |
|
||||||
|
|
||||||
@IBOutlet weak var textViewBottomConstraint: NSLayoutConstraint! |
|
||||||
|
|
||||||
fileprivate weak var _lastEditLabel: UILabel? |
|
||||||
|
|
||||||
// MARK: - |
|
||||||
|
|
||||||
override func viewDidLoad() { |
|
||||||
super.viewDidLoad() |
|
||||||
|
|
||||||
self.textView.text = self.note?.content |
|
||||||
|
|
||||||
self.textView.font = UIFont.systemFont(ofSize: 18.0, weight: .regular) |
|
||||||
self.textView.delegate = self |
|
||||||
self.textView.inputAccessoryView = self._inputAccessoryView() |
|
||||||
|
|
||||||
self._updateLastEdit() |
|
||||||
|
|
||||||
/// Store notifications |
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(self._storeRemoteChange(notification:)), name: NSPersistentCloudKitContainer.eventChangedNotification, object: nil) |
|
||||||
|
|
||||||
/// Keyboard notifications |
|
||||||
|
|
||||||
NotificationCenter.default.addObserver( |
|
||||||
self, |
|
||||||
selector: #selector(keyboardDidShow(notification:)), |
|
||||||
name: UIResponder.keyboardDidShowNotification, |
|
||||||
object: nil |
|
||||||
) |
|
||||||
|
|
||||||
NotificationCenter.default.addObserver( |
|
||||||
self, |
|
||||||
selector: #selector(keyboardWillHide), |
|
||||||
name: UIResponder.keyboardWillHideNotification, |
|
||||||
object: nil |
|
||||||
) |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
func loadText() { |
|
||||||
self.textView.text = self.note?.content |
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _loadLastNote() { |
|
||||||
|
|
||||||
let request = Note.fetchRequest() |
|
||||||
request.sortDescriptors = [NSSortDescriptor(key: "lastEditDate", ascending: false)] |
|
||||||
|
|
||||||
do { |
|
||||||
let notes = try AppDelegate.viewContext.fetch(request) |
|
||||||
|
|
||||||
print("notes in store : \(notes.count)") |
|
||||||
|
|
||||||
self.note = notes.first |
|
||||||
} catch { |
|
||||||
print("Fetch error = \(error)") |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
@objc fileprivate func _storeRemoteChange(notification: Notification) { |
|
||||||
print("_storeRemoteChange...") |
|
||||||
DispatchQueue.main.async { |
|
||||||
// self._loadLastNote() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func textViewDidChange(_ textView: UITextView) { |
|
||||||
PreferencesStorage.main.requestStorage(filename: "main.txt", content: textView.text) |
|
||||||
|
|
||||||
let note: Note |
|
||||||
if let n = self.note { |
|
||||||
note = n |
|
||||||
} else { |
|
||||||
note = Note(context: AppDelegate.viewContext) |
|
||||||
self.note = note |
|
||||||
} |
|
||||||
|
|
||||||
CoreDataStorage.main.requestStorage(note: note, content: textView.text) |
|
||||||
|
|
||||||
self._updateLastEdit() |
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _updateLastEdit() { |
|
||||||
|
|
||||||
if let date = self.note?.lastEditDate { |
|
||||||
let formattedDate: String = date.formatted() |
|
||||||
self._lastEditLabel?.text = "last edit: \(formattedDate)" |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _inputAccessoryView() -> UIView { |
|
||||||
|
|
||||||
let toolbar: UIToolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: 333, height: 44.0)) |
|
||||||
|
|
||||||
let lastEditLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 280, height: 44)) |
|
||||||
lastEditLabel.textColor = UIColor.gray |
|
||||||
let lastEditButtonItem = UIBarButtonItem(customView: lastEditLabel) |
|
||||||
self._lastEditLabel = lastEditLabel |
|
||||||
|
|
||||||
let flexButton = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) |
|
||||||
let dismissButton = UIBarButtonItem(image: UIImage(systemName: "checkmark"), style: .done, target: self, action: #selector(dismissKeyboard)) |
|
||||||
toolbar.items = [lastEditButtonItem, flexButton, dismissButton] |
|
||||||
|
|
||||||
let swipeDown = UISwipeGestureRecognizer(target: self, action: #selector(dismissKeyboard)) |
|
||||||
swipeDown.direction = .down |
|
||||||
toolbar.addGestureRecognizer(swipeDown) |
|
||||||
|
|
||||||
return toolbar |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Keyboard |
|
||||||
|
|
||||||
@objc func dismissKeyboard() { |
|
||||||
self.textView.resignFirstResponder() |
|
||||||
} |
|
||||||
|
|
||||||
@objc func keyboardDidShow(notification: NSNotification) { |
|
||||||
guard let keyboardRect = notification |
|
||||||
.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] |
|
||||||
as? NSValue else { return } |
|
||||||
let frameKeyboard = keyboardRect.cgRectValue |
|
||||||
|
|
||||||
self.textViewBottomConstraint.constant = frameKeyboard.size.height |
|
||||||
} |
|
||||||
|
|
||||||
@objc func keyboardWillHide() { |
|
||||||
self.textViewBottomConstraint.constant = 0.0 |
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Business |
|
||||||
|
|
||||||
deinit { |
|
||||||
NotificationCenter.default.removeObserver(self) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -0,0 +1,22 @@ |
|||||||
|
// |
||||||
|
// NotesApp.swift |
||||||
|
// Notes |
||||||
|
// |
||||||
|
// Created by Claude Code on 13/10/2025. |
||||||
|
// |
||||||
|
|
||||||
|
import SwiftUI |
||||||
|
|
||||||
|
@main |
||||||
|
struct NotesApp: App { |
||||||
|
let persistenceController = PersistenceController.shared |
||||||
|
@StateObject private var cloudKitMonitor = CloudKitSyncMonitor() |
||||||
|
|
||||||
|
var body: some Scene { |
||||||
|
WindowGroup { |
||||||
|
NotesPageView() |
||||||
|
.environment(\.managedObjectContext, persistenceController.container.viewContext) |
||||||
|
.environmentObject(cloudKitMonitor) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,144 @@ |
|||||||
|
// |
||||||
|
// NotesPageView.swift |
||||||
|
// Notes |
||||||
|
// |
||||||
|
// Created by Claude Code on 13/10/2025. |
||||||
|
// |
||||||
|
|
||||||
|
import SwiftUI |
||||||
|
import CoreData |
||||||
|
|
||||||
|
struct NotesPageView: View { |
||||||
|
@Environment(\.managedObjectContext) private var viewContext |
||||||
|
|
||||||
|
@FetchRequest( |
||||||
|
sortDescriptors: [NSSortDescriptor(keyPath: \Note.lastEditDate, ascending: true)], |
||||||
|
animation: .default) |
||||||
|
private var notes: FetchedResults<Note> |
||||||
|
|
||||||
|
@State private var selectedIndex: Int = 0 |
||||||
|
@State private var showDeleteAlert = false |
||||||
|
|
||||||
|
var body: some View { |
||||||
|
NavigationStack { |
||||||
|
VStack { |
||||||
|
|
||||||
|
if notes.isEmpty { |
||||||
|
Text("No notes") |
||||||
|
.foregroundColor(.gray) |
||||||
|
} else { |
||||||
|
TabView(selection: $selectedIndex) { |
||||||
|
ForEach(Array(notes.enumerated()), id: \.element.objectID) { index, note in |
||||||
|
NoteEditorView(note: note) |
||||||
|
.tag(index) |
||||||
|
} |
||||||
|
} |
||||||
|
.tabViewStyle(.page(indexDisplayMode: .never)) |
||||||
|
} |
||||||
|
} |
||||||
|
.navigationBarTitleDisplayMode(.inline) |
||||||
|
.toolbar { |
||||||
|
ToolbarItemGroup(placement: .navigationBarTrailing) { |
||||||
|
Button(action: addNote) { |
||||||
|
Image(systemName: "plus") |
||||||
|
} |
||||||
|
|
||||||
|
Button(action: shareNote) { |
||||||
|
Image(systemName: "square.and.arrow.up") |
||||||
|
} |
||||||
|
.disabled(notes.isEmpty) |
||||||
|
|
||||||
|
Button(action: { showDeleteAlert = true }) { |
||||||
|
Image(systemName: "trash") |
||||||
|
} |
||||||
|
.disabled(notes.isEmpty) |
||||||
|
} |
||||||
|
} |
||||||
|
.alert("Delete Note", isPresented: $showDeleteAlert) { |
||||||
|
Button("Cancel", role: .cancel) { } |
||||||
|
Button("Delete", role: .destructive) { |
||||||
|
deleteNote() |
||||||
|
} |
||||||
|
} message: { |
||||||
|
if selectedIndex < notes.count { |
||||||
|
let content = notes[selectedIndex].content ?? "" |
||||||
|
Text("Do you want to delete this: \(content.prefix(20))...?") |
||||||
|
} |
||||||
|
} |
||||||
|
.onAppear { |
||||||
|
createNoteIfNeeded() |
||||||
|
selectLastNote() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private func addNote() { |
||||||
|
withAnimation { |
||||||
|
let newNote = Note(context: viewContext) |
||||||
|
newNote.content = "" |
||||||
|
newNote.lastEditDate = Date() |
||||||
|
|
||||||
|
PersistenceController.shared.save() |
||||||
|
|
||||||
|
// Select the newly created note (last in the list) |
||||||
|
selectedIndex = notes.count - 1 |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private func deleteNote() { |
||||||
|
guard selectedIndex < notes.count else { return } |
||||||
|
|
||||||
|
withAnimation { |
||||||
|
let noteToDelete = notes[selectedIndex] |
||||||
|
viewContext.delete(noteToDelete) |
||||||
|
PersistenceController.shared.save() |
||||||
|
|
||||||
|
// Select the last note after deletion |
||||||
|
if !notes.isEmpty { |
||||||
|
selectedIndex = min(selectedIndex, notes.count - 1) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private func shareNote() { |
||||||
|
guard selectedIndex < notes.count, |
||||||
|
let content = notes[selectedIndex].content, |
||||||
|
!content.isEmpty else { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
let activityVC = UIActivityViewController( |
||||||
|
activityItems: [content], |
||||||
|
applicationActivities: nil |
||||||
|
) |
||||||
|
|
||||||
|
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, |
||||||
|
let window = windowScene.windows.first, |
||||||
|
let rootVC = window.rootViewController { |
||||||
|
activityVC.popoverPresentationController?.sourceView = rootVC.view |
||||||
|
rootVC.present(activityVC, animated: true) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private func createNoteIfNeeded() { |
||||||
|
if notes.isEmpty { |
||||||
|
let newNote = Note(context: viewContext) |
||||||
|
newNote.content = "" |
||||||
|
newNote.lastEditDate = Date() |
||||||
|
PersistenceController.shared.save() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private func selectLastNote() { |
||||||
|
if !notes.isEmpty { |
||||||
|
selectedIndex = notes.count - 1 |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
struct NotesPageView_Previews: PreviewProvider { |
||||||
|
static var previews: some View { |
||||||
|
NotesPageView() |
||||||
|
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) |
||||||
|
} |
||||||
|
} |
||||||
@ -1,156 +0,0 @@ |
|||||||
// |
|
||||||
// NotesPageViewController.swift |
|
||||||
// Notes |
|
||||||
// |
|
||||||
// Created by Laurent Morvillier on 16/10/2022. |
|
||||||
// |
|
||||||
|
|
||||||
import Foundation |
|
||||||
import UIKit |
|
||||||
|
|
||||||
class NotesPageViewController : UIPageViewController, UIPageViewControllerDelegate, UIPageViewControllerDataSource { |
|
||||||
|
|
||||||
fileprivate var _notes: [Note] = [] |
|
||||||
|
|
||||||
fileprivate var _currentIndex: Int = 0 |
|
||||||
|
|
||||||
override func viewDidLoad() { |
|
||||||
super.viewDidLoad() |
|
||||||
|
|
||||||
self._loadNotes() |
|
||||||
|
|
||||||
let shareButton = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(shareHandler)) |
|
||||||
let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addHandler)) |
|
||||||
let deleteButton = UIBarButtonItem(barButtonSystemItem: .trash, target: self, action: #selector(deleteHandler)) |
|
||||||
self.navigationItem.rightBarButtonItems = [addButton, shareButton, deleteButton] |
|
||||||
self.view.backgroundColor = UIColor(white: 0.95, alpha: 1.0) |
|
||||||
|
|
||||||
self.dataSource = self |
|
||||||
self.delegate = self |
|
||||||
|
|
||||||
self._displayLastNote() |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _displayLastNote(animated: Bool = false) { |
|
||||||
if let note = self._notes.last { |
|
||||||
let index = self._notes.count - 1 |
|
||||||
let vc = self.viewController(note: note, index: index) |
|
||||||
self.setViewControllers([vc], direction: .forward, animated: animated) |
|
||||||
self._currentIndex = index |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _displayNote(note: Note, animated: Bool = false) { |
|
||||||
if let index = self._notes.firstIndex(of: note) { |
|
||||||
let vc = self.viewController(note: note, index: index) |
|
||||||
self.setViewControllers([vc], direction: .forward, animated: animated) |
|
||||||
} else { |
|
||||||
print("note not found") |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func viewController(note: Note, index: Int) -> NoteViewController { |
|
||||||
if let vc = self.storyboard?.instantiateViewController(withIdentifier: "note") as? NoteViewController { |
|
||||||
vc.note = note |
|
||||||
vc.index = index |
|
||||||
return vc |
|
||||||
} else { |
|
||||||
fatalError("error with storyboard") |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _loadNotes() { |
|
||||||
|
|
||||||
do { |
|
||||||
self._notes = try Note.fetchByDate() |
|
||||||
if self._notes.isEmpty { |
|
||||||
self._createNote() |
|
||||||
self._loadNotes() |
|
||||||
} |
|
||||||
} catch { |
|
||||||
print("error = \(error)") |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - UIPageViewControllerDataSource |
|
||||||
|
|
||||||
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { |
|
||||||
|
|
||||||
guard let vc = viewController as? NoteViewController else { |
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
let previousIndex = vc.index - 1 |
|
||||||
if previousIndex < 0 { |
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
let note = self._notes[previousIndex] |
|
||||||
return self.viewController(note: note, index: previousIndex) |
|
||||||
} |
|
||||||
|
|
||||||
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { |
|
||||||
|
|
||||||
guard let vc = viewController as? NoteViewController else { |
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
let nextIndex = vc.index + 1 |
|
||||||
if nextIndex == self._notes.count { |
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
let note = self._notes[nextIndex] |
|
||||||
return self.viewController(note: note, index: nextIndex) |
|
||||||
} |
|
||||||
|
|
||||||
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { |
|
||||||
|
|
||||||
if completed, let vc = pageViewController.viewControllers?.first as? NoteViewController { |
|
||||||
self._currentIndex = vc.index |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
// MARK: - Business |
|
||||||
|
|
||||||
@objc func shareHandler() { |
|
||||||
|
|
||||||
guard let text = self._notes[self._currentIndex].content else { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
let activityViewController = UIActivityViewController(activityItems: [text], applicationActivities: nil) |
|
||||||
self.present(activityViewController, animated: true) |
|
||||||
} |
|
||||||
|
|
||||||
@objc func addHandler() { |
|
||||||
self._createNote() |
|
||||||
} |
|
||||||
|
|
||||||
@objc func deleteHandler() { |
|
||||||
|
|
||||||
let note = self._notes[self._currentIndex] |
|
||||||
guard let content = note.content else { |
|
||||||
self.showAlert(message: "Sorry!", title: "Note not found") |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
areYouSure(message: "Do you want to delete this: \(content.prefix(20))...?") { |
|
||||||
AppDelegate.viewContext.delete(note) |
|
||||||
AppDelegate.shared.saveContext() |
|
||||||
self._loadNotes() |
|
||||||
self._displayLastNote(animated: true) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
fileprivate func _createNote() { |
|
||||||
let note = Note(context: AppDelegate.viewContext) |
|
||||||
self._loadNotes() |
|
||||||
self._displayNote(note: note) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -0,0 +1,63 @@ |
|||||||
|
// |
||||||
|
// PersistenceController.swift |
||||||
|
// Notes |
||||||
|
// |
||||||
|
// Created by Claude Code on 13/10/2025. |
||||||
|
// |
||||||
|
|
||||||
|
import CoreData |
||||||
|
|
||||||
|
struct PersistenceController { |
||||||
|
static let shared = PersistenceController() |
||||||
|
|
||||||
|
static var preview: PersistenceController = { |
||||||
|
let result = PersistenceController(inMemory: true) |
||||||
|
let viewContext = result.container.viewContext |
||||||
|
|
||||||
|
// Create sample notes for preview |
||||||
|
for i in 0..<3 { |
||||||
|
let newNote = Note(context: viewContext) |
||||||
|
newNote.content = "Sample note \(i + 1)\n\nThis is some sample content for testing." |
||||||
|
newNote.lastEditDate = Date() |
||||||
|
} |
||||||
|
|
||||||
|
do { |
||||||
|
try viewContext.save() |
||||||
|
} catch { |
||||||
|
let nsError = error as NSError |
||||||
|
fatalError("Unresolved error \(nsError), \(nsError.userInfo)") |
||||||
|
} |
||||||
|
return result |
||||||
|
}() |
||||||
|
|
||||||
|
let container: NSPersistentCloudKitContainer |
||||||
|
|
||||||
|
init(inMemory: Bool = false) { |
||||||
|
container = NSPersistentCloudKitContainer(name: "Notes") |
||||||
|
|
||||||
|
if inMemory { |
||||||
|
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") |
||||||
|
} |
||||||
|
|
||||||
|
container.loadPersistentStores(completionHandler: { (storeDescription, error) in |
||||||
|
if let error = error as NSError? { |
||||||
|
fatalError("Unresolved error \(error), \(error.userInfo)") |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
container.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy |
||||||
|
container.viewContext.automaticallyMergesChangesFromParent = true |
||||||
|
} |
||||||
|
|
||||||
|
func save() { |
||||||
|
let context = container.viewContext |
||||||
|
if context.hasChanges { |
||||||
|
do { |
||||||
|
try context.save() |
||||||
|
} catch { |
||||||
|
let nsError = error as NSError |
||||||
|
print("Unresolved error \(nsError), \(nsError.userInfo)") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -1,55 +0,0 @@ |
|||||||
// |
|
||||||
// SceneDelegate.swift |
|
||||||
// Notes |
|
||||||
// |
|
||||||
// Created by Laurent Morvillier on 21/09/2022. |
|
||||||
// |
|
||||||
|
|
||||||
import UIKit |
|
||||||
|
|
||||||
class SceneDelegate: 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 _ = (scene as? UIWindowScene) else { return } |
|
||||||
} |
|
||||||
|
|
||||||
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 necessarily 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. |
|
||||||
|
|
||||||
// Save changes in the application's managed object context when the application transitions to the background. |
|
||||||
(UIApplication.shared.delegate as? AppDelegate)?.saveContext() |
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
@ -1,35 +0,0 @@ |
|||||||
// |
|
||||||
// UIViewController+Extensions.swift |
|
||||||
// Notes |
|
||||||
// |
|
||||||
// Created by Laurent Morvillier on 19/09/2022. |
|
||||||
// |
|
||||||
|
|
||||||
import Foundation |
|
||||||
import UIKit |
|
||||||
|
|
||||||
extension UIViewController { |
|
||||||
|
|
||||||
@objc public func showAlert(message: String, title: String?, completion: (() -> ())? = nil) { |
|
||||||
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) |
|
||||||
let action = UIAlertAction(title: NSLocalizedString("OK", comment: "OK"), style: .default, handler: nil) |
|
||||||
alertController.addAction(action) |
|
||||||
self.present(alertController, animated: true, completion: completion) |
|
||||||
} |
|
||||||
|
|
||||||
func areYouSure(title: String? = nil, |
|
||||||
message: String? = NSLocalizedString("Please confirm the action", comment: ""), |
|
||||||
executable: @escaping () -> Void) { |
|
||||||
|
|
||||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) |
|
||||||
alert.addAction(UIAlertAction(title: NSLocalizedString("No", comment:""), style: .cancel, handler: { _ in |
|
||||||
// do nothing |
|
||||||
})) |
|
||||||
alert.addAction(UIAlertAction(title: NSLocalizedString("Yes", comment:""), style: .destructive, handler: { action in |
|
||||||
executable() |
|
||||||
})) |
|
||||||
self.present(alert, animated: true, completion: nil) |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
@ -1,19 +0,0 @@ |
|||||||
// |
|
||||||
// ViewController.swift |
|
||||||
// Notes |
|
||||||
// |
|
||||||
// Created by Laurent Morvillier on 21/09/2022. |
|
||||||
// |
|
||||||
|
|
||||||
import UIKit |
|
||||||
|
|
||||||
class ViewController : UIViewController { |
|
||||||
|
|
||||||
override func viewDidLoad() { |
|
||||||
super.viewDidLoad() |
|
||||||
// Do any additional setup after loading the view. |
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Loading…
Reference in new issue