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