diff --git a/Memola.xcodeproj/project.pbxproj b/Memola.xcodeproj/project.pbxproj index e57b134..55311f8 100644 --- a/Memola.xcodeproj/project.pbxproj +++ b/Memola.xcodeproj/project.pbxproj @@ -7,10 +7,21 @@ objects = { /* Begin PBXBuildFile section */ + EC01511E2C305CA9008A115E /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC01511D2C305CA9008A115E /* DashboardView.swift */; }; + EC0151202C305D7B008A115E /* SidebarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC01511F2C305D7B008A115E /* SidebarItem.swift */; }; + EC0151232C306089008A115E /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC0151222C306089008A115E /* Sidebar.swift */; }; + EC0151262C3067B9008A115E /* TrashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC0151252C3067B9008A115E /* TrashView.swift */; }; + EC01512A2C306935008A115E /* MemoGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC0151292C306935008A115E /* MemoGrid.swift */; }; + EC01512C2C306BEF008A115E /* MemoCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC01512B2C306BEF008A115E /* MemoCard.swift */; }; + EC01512E2C30727F008A115E /* MemoPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC01512D2C30727F008A115E /* MemoPreview.swift */; }; EC0D14212BF79C73009BFE5F /* ToolObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC0D14202BF79C73009BFE5F /* ToolObject.swift */; }; EC0D14242BF79C98009BFE5F /* MemolaModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = EC0D14222BF79C98009BFE5F /* MemolaModel.xcdatamodeld */; }; EC0D14262BF7A8C9009BFE5F /* PenObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC0D14252BF7A8C9009BFE5F /* PenObject.swift */; }; EC0D14282BF7BF20009BFE5F /* ContextMenuViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC0D14272BF7BF20009BFE5F /* ContextMenuViewModifier.swift */; }; + EC1815082C2D980B00541369 /* Sort.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC1815072C2D980B00541369 /* Sort.swift */; }; + EC18150A2C2DA09E00541369 /* Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC1815092C2DA09E00541369 /* Filter.swift */; }; + EC18150D2C2DAC3700541369 /* Placeholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC18150C2C2DAC3700541369 /* Placeholder.swift */; }; + EC18150F2C2DB13200541369 /* Date++.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC18150E2C2DB13200541369 /* Date++.swift */; }; EC1B783D2BFA0AC9005A34E2 /* Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC1B783C2BFA0AC9005A34E2 /* Toolbar.swift */; }; EC2106AD2C10C2A700FBE27C /* AnyStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC2106AC2C10C2A700FBE27C /* AnyStroke.swift */; }; EC2BEBF42C0F5FF7005DB0AF /* RTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC2BEBF32C0F5FF7005DB0AF /* RTree.swift */; }; @@ -100,6 +111,7 @@ ECD12A912C1B04EA00B96E12 /* PhotoRenderPass.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A902C1B04EA00B96E12 /* PhotoRenderPass.swift */; }; ECD12A932C1B062000B96E12 /* Photo.metal in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A922C1B062000B96E12 /* Photo.metal */; }; ECD12A952C1B1FA200B96E12 /* PhotoVertex.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A942C1B1FA200B96E12 /* PhotoVertex.swift */; }; + ECDAC07B2C318DBC0000ED77 /* ElementToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECDAC07A2C318DBC0000ED77 /* ElementToolbar.swift */; }; ECE883BD2C00AA170045C53D /* EraserStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE883BC2C00AA170045C53D /* EraserStroke.swift */; }; ECE883BF2C00AB440045C53D /* Stroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE883BE2C00AB440045C53D /* Stroke.swift */; }; ECE883C12C00C9CB0045C53D /* StrokeStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE883C02C00C9CB0045C53D /* StrokeStyle.swift */; }; @@ -113,10 +125,21 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + EC01511D2C305CA9008A115E /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; + EC01511F2C305D7B008A115E /* SidebarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarItem.swift; sourceTree = ""; }; + EC0151222C306089008A115E /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = ""; }; + EC0151252C3067B9008A115E /* TrashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashView.swift; sourceTree = ""; }; + EC0151292C306935008A115E /* MemoGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoGrid.swift; sourceTree = ""; }; + EC01512B2C306BEF008A115E /* MemoCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoCard.swift; sourceTree = ""; }; + EC01512D2C30727F008A115E /* MemoPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoPreview.swift; sourceTree = ""; }; EC0D14202BF79C73009BFE5F /* ToolObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolObject.swift; sourceTree = ""; }; EC0D14232BF79C98009BFE5F /* MemolaModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MemolaModel.xcdatamodel; sourceTree = ""; }; EC0D14252BF7A8C9009BFE5F /* PenObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PenObject.swift; sourceTree = ""; }; EC0D14272BF7BF20009BFE5F /* ContextMenuViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuViewModifier.swift; sourceTree = ""; }; + EC1815072C2D980B00541369 /* Sort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sort.swift; sourceTree = ""; }; + EC1815092C2DA09E00541369 /* Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filter.swift; sourceTree = ""; }; + EC18150C2C2DAC3700541369 /* Placeholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Placeholder.swift; sourceTree = ""; }; + EC18150E2C2DB13200541369 /* Date++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date++.swift"; sourceTree = ""; }; EC1B783C2BFA0AC9005A34E2 /* Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toolbar.swift; sourceTree = ""; }; EC2106AC2C10C2A700FBE27C /* AnyStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyStroke.swift; sourceTree = ""; }; EC2BEBF32C0F5FF7005DB0AF /* RTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTree.swift; sourceTree = ""; }; @@ -208,6 +231,7 @@ ECD12A902C1B04EA00B96E12 /* PhotoRenderPass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoRenderPass.swift; sourceTree = ""; }; ECD12A922C1B062000B96E12 /* Photo.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Photo.metal; sourceTree = ""; }; ECD12A942C1B1FA200B96E12 /* PhotoVertex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoVertex.swift; sourceTree = ""; }; + ECDAC07A2C318DBC0000ED77 /* ElementToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementToolbar.swift; sourceTree = ""; }; ECE883BC2C00AA170045C53D /* EraserStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EraserStroke.swift; sourceTree = ""; }; ECE883BE2C00AB440045C53D /* Stroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stroke.swift; sourceTree = ""; }; ECE883C02C00C9CB0045C53D /* StrokeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrokeStyle.swift; sourceTree = ""; }; @@ -231,6 +255,61 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + EC01511A2C305ABB008A115E /* Dashboard */ = { + isa = PBXGroup; + children = ( + EC0151272C306906008A115E /* Details */, + EC0151212C30605F008A115E /* Sidebar */, + EC01511C2C305C99008A115E /* Dashboard */, + ); + path = Dashboard; + sourceTree = ""; + }; + EC01511C2C305C99008A115E /* Dashboard */ = { + isa = PBXGroup; + children = ( + EC01511D2C305CA9008A115E /* DashboardView.swift */, + ); + path = Dashboard; + sourceTree = ""; + }; + EC0151212C30605F008A115E /* Sidebar */ = { + isa = PBXGroup; + children = ( + EC0151222C306089008A115E /* Sidebar.swift */, + EC01511F2C305D7B008A115E /* SidebarItem.swift */, + ); + path = Sidebar; + sourceTree = ""; + }; + EC0151242C3067B2008A115E /* Trash */ = { + isa = PBXGroup; + children = ( + EC0151252C3067B9008A115E /* TrashView.swift */, + ); + path = Trash; + sourceTree = ""; + }; + EC0151272C306906008A115E /* Details */ = { + isa = PBXGroup; + children = ( + EC0151282C306927008A115E /* Shared */, + EC0151242C3067B2008A115E /* Trash */, + ECA738782BE5EEF700A4542E /* Memos */, + ); + path = Details; + sourceTree = ""; + }; + EC0151282C306927008A115E /* Shared */ = { + isa = PBXGroup; + children = ( + EC0151292C306935008A115E /* MemoGrid.swift */, + EC01512B2C306BEF008A115E /* MemoCard.swift */, + EC01512D2C30727F008A115E /* MemoPreview.swift */, + ); + path = Shared; + sourceTree = ""; + }; EC1437B42BE748E60022C903 /* Views */ = { isa = PBXGroup; children = ( @@ -247,9 +326,18 @@ path = ViewController; sourceTree = ""; }; + EC18150B2C2DA3AD00541369 /* Placeholder */ = { + isa = PBXGroup; + children = ( + EC18150C2C2DAC3700541369 /* Placeholder.swift */, + ); + path = Placeholder; + sourceTree = ""; + }; EC1B783A2BF9C68C005A34E2 /* Views */ = { isa = PBXGroup; children = ( + EC18150B2C2DA3AD00541369 /* Placeholder */, ECBE529B2C1D94A4006BDB3D /* CameraView */, ECFC51252BF8885000D0D051 /* ColorPicker */, ); @@ -401,8 +489,8 @@ ECA738772BE5EEE800A4542E /* Features */ = { isa = PBXGroup; children = ( + EC01511A2C305ABB008A115E /* Dashboard */, ECA7387B2BE5EF3500A4542E /* Memo */, - ECA738782BE5EEF700A4542E /* Memos */, ); path = Features; sourceTree = ""; @@ -410,6 +498,8 @@ ECA738782BE5EEF700A4542E /* Memos */ = { isa = PBXGroup; children = ( + EC1815072C2D980B00541369 /* Sort.swift */, + EC1815092C2DA09E00541369 /* Filter.swift */, ECA738792BE5EF0400A4542E /* MemosView.swift */, ); path = Memos; @@ -418,6 +508,7 @@ ECA7387B2BE5EF3500A4542E /* Memo */ = { isa = PBXGroup; children = ( + ECDAC0792C318DAF0000ED77 /* ElementToolbar */, ECBE52942C1D58F5006BDB3D /* PhotoPreview */, EC1B783B2BFA0AAC005A34E2 /* Toolbar */, EC5050082BF65D0500B4D86E /* Memo */, @@ -537,6 +628,7 @@ EC35655B2BF0712A00A4E0BF /* Float++.swift */, ECBE529D2C1DAB21006BDB3D /* UIImage++.swift */, ECC995A42C1EB4CC00B2699A /* Data++.swift */, + EC18150E2C2DB13200541369 /* Date++.swift */, ); path = Extensions; sourceTree = ""; @@ -745,6 +837,14 @@ path = Photo; sourceTree = ""; }; + ECDAC0792C318DAF0000ED77 /* ElementToolbar */ = { + isa = PBXGroup; + children = ( + ECDAC07A2C318DBC0000ED77 /* ElementToolbar.swift */, + ); + path = ElementToolbar; + sourceTree = ""; + }; ECE883B82C009DC30045C53D /* Strokes */ = { isa = PBXGroup; children = ( @@ -866,6 +966,7 @@ files = ( ECA738B02BE60D0B00A4542E /* CanvasViewController.swift in Sources */, ECD12A912C1B04EA00B96E12 /* PhotoRenderPass.swift in Sources */, + EC18150A2C2DA09E00541369 /* Filter.swift in Sources */, EC5D40812C21CE270067F090 /* PhotoBackgroundRenderPass.swift in Sources */, ECA738E42BE6110800A4542E /* Drawable.swift in Sources */, ECA738AD2BE60CC600A4542E /* DrawingView.swift in Sources */, @@ -876,6 +977,7 @@ ECA738912BE600F500A4542E /* Cache.metal in Sources */, ECA7389C2BE601AF00A4542E /* PointGridVertex.swift in Sources */, ECA738A82BE6025900A4542E /* GraphicUniforms.swift in Sources */, + EC01511E2C305CA9008A115E /* DashboardView.swift in Sources */, EC8F54AE2C2AF5A4001C7C74 /* LineGridVertex.swift in Sources */, ECA738E62BE611FD00A4542E /* CGRect++.swift in Sources */, EC5E83902BFDB69C00261D9C /* MovingAverage.swift in Sources */, @@ -887,6 +989,7 @@ EC3565522BEFC65F00A4E0BF /* NSManagedObjectContext++.swift in Sources */, ECFA15222BEF21F500455818 /* CanvasObject.swift in Sources */, ECA738AA2BE6026D00A4542E /* Uniforms.swift in Sources */, + EC1815082C2D980B00541369 /* Sort.swift in Sources */, ECA7387D2BE5EF4B00A4542E /* MemoView.swift in Sources */, ECA738DA2BE60FF100A4542E /* CacheRenderPass.swift in Sources */, ECA738CD2BE60F2F00A4542E /* PointGridContext.swift in Sources */, @@ -904,6 +1007,7 @@ ECEC01A82BEE11BA006DA24C /* QuadShape.swift in Sources */, ECA738862BE5FF2500A4542E /* Canvas.swift in Sources */, ECA738882BE5FF4400A4542E /* Renderer.swift in Sources */, + EC01512C2C306BEF008A115E /* MemoCard.swift in Sources */, ECA738D42BE60F9100A4542E /* StrokeGenerator.swift in Sources */, ECA739082BE623F300A4542E /* PenDock.swift in Sources */, ECA738CB2BE60F1900A4542E /* ViewPortContext.swift in Sources */, @@ -929,24 +1033,31 @@ EC4538892BEBCAE000A86FEC /* Quad.swift in Sources */, ECE883BD2C00AA170045C53D /* EraserStroke.swift in Sources */, EC42F7852C25267000E86E96 /* ElementGroup.swift in Sources */, + EC18150F2C2DB13200541369 /* Date++.swift in Sources */, ECD12A8F2C1AEBA400B96E12 /* Photo.swift in Sources */, + EC0151232C306089008A115E /* Sidebar.swift in Sources */, ECD12A932C1B062000B96E12 /* Photo.metal in Sources */, ECA7388F2BE600DA00A4542E /* Grid.metal in Sources */, EC2BEBF42C0F5FF7005DB0AF /* RTree.swift in Sources */, ECA738C92BE60EF700A4542E /* GraphicContext.swift in Sources */, ECA738F62BE612B700A4542E /* MTLDevice++.swift in Sources */, EC0D14212BF79C73009BFE5F /* ToolObject.swift in Sources */, + ECDAC07B2C318DBC0000ED77 /* ElementToolbar.swift in Sources */, EC50500D2BF6674400B4D86E /* OnDragViewModifier.swift in Sources */, EC9AB09F2C1401A40076AF58 /* EraserObject.swift in Sources */, ECBE529C2C1D94A4006BDB3D /* CameraView.swift in Sources */, ECA7389E2BE601CB00A4542E /* QuadVertex.swift in Sources */, ECA738B32BE60D9E00A4542E /* CanvasView.swift in Sources */, + EC0151202C305D7B008A115E /* SidebarItem.swift in Sources */, + EC01512A2C306935008A115E /* MemoGrid.swift in Sources */, ECA738C42BE60E8800A4542E /* MarkerPenStyle.swift in Sources */, + EC0151262C3067B9008A115E /* TrashView.swift in Sources */, ECA738BF2BE60E3400A4542E /* Pen.swift in Sources */, ECFA15282BEF225000455818 /* QuadObject.swift in Sources */, ECA738932BE6011100A4542E /* Stroke.metal in Sources */, ECA738B62BE60DCD00A4542E /* History.swift in Sources */, ECA738D22BE60F7B00A4542E /* PenStroke.swift in Sources */, + EC01512E2C30727F008A115E /* MemoPreview.swift in Sources */, ECA738F22BE6128F00A4542E /* Collection++.swift in Sources */, EC5050072BF65CED00B4D86E /* PenDropDelegate.swift in Sources */, ECA738A32BE6020A00A4542E /* CGFloat++.swift in Sources */, @@ -965,6 +1076,7 @@ ECA738BC2BE60E0300A4542E /* Tool.swift in Sources */, ECA738972BE6014200A4542E /* Graphic.metal in Sources */, ECA7388A2BE6006A00A4542E /* PipelineStates.swift in Sources */, + EC18150D2C2DAC3700541369 /* Placeholder.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Memola/App/MemolaApp.swift b/Memola/App/MemolaApp.swift index 4804ebc..c5aca74 100644 --- a/Memola/App/MemolaApp.swift +++ b/Memola/App/MemolaApp.swift @@ -11,9 +11,12 @@ import SwiftUI struct MemolaApp: App { var body: some Scene { WindowGroup { - MemosView() + DashboardView() .persistence(\.viewContext) .onReceive(NotificationCenter.default.publisher(for: UIApplication.willTerminateNotification)) { _ in + withPersistenceSync(\.viewContext) { context in + try context.saveIfNeeded() + } withPersistenceSync(\.backgroundContext) { context in try context.saveIfNeeded() } diff --git a/Memola/Canvas/History/History.swift b/Memola/Canvas/History/History.swift index a6d152b..a23b8eb 100644 --- a/Memola/Canvas/History/History.swift +++ b/Memola/Canvas/History/History.swift @@ -9,6 +9,12 @@ import Combine import Foundation class History: ObservableObject { + var memo: MemoObject? + + init(memo: MemoObject?) { + self.memo = memo + } + @Published var undoStack: [HistoryEvent] = [] @Published var redoStack: [HistoryEvent] = [] @@ -41,10 +47,18 @@ class History: ObservableObject { func addUndo(_ event: HistoryEvent) { undoStack.append(event) + withPersistence(\.viewContext) { [weak memo] context in + memo?.updatedAt = .now + try context.saveIfNeeded() + } } func addRedo(_ event: HistoryEvent) { redoStack.append(event) + withPersistence(\.viewContext) { [weak memo] context in + memo?.updatedAt = .now + try context.saveIfNeeded() + } } func resetRedo() { @@ -87,6 +101,10 @@ class History: ObservableObject { } } redoStack.removeAll() + withPersistence(\.viewContext) { [weak memo] context in + memo?.updatedAt = .now + try context.saveIfNeeded() + } } func restoreUndo() { diff --git a/Memola/Canvas/Tool/Core/Tool.swift b/Memola/Canvas/Tool/Core/Tool.swift index 18502f7..38d9b9e 100644 --- a/Memola/Canvas/Tool/Core/Tool.swift +++ b/Memola/Canvas/Tool/Core/Tool.swift @@ -38,6 +38,7 @@ public class Tool: NSObject, ObservableObject { self.selection = selection withPersistence(\.viewContext) { [weak object] context in object?.selection = selection.rawValue + object?.memo?.updatedAt = .now try context.saveIfNeeded() } } @@ -85,10 +86,11 @@ public class Tool: NSObject, ObservableObject { pens.insert(pen, at: index + 1) } selectPen(pen) - withPersistence(\.viewContext) { [pens] context in + withPersistence(\.viewContext) { [pens, weak object] context in for (index, pen) in pens.enumerated() { pen.object?.orderIndex = Int16(index) } + object?.memo?.updatedAt = .now try context.saveIfNeeded() } } @@ -102,7 +104,8 @@ public class Tool: NSObject, ObservableObject { object.pens.add(_pen) } scrollPublisher.send(pen.id) - withPersistence(\.viewContext) { context in + withPersistence(\.viewContext) { [weak object] context in + object?.memo?.updatedAt = .now try context.saveIfNeeded() } } @@ -116,8 +119,9 @@ public class Tool: NSObject, ObservableObject { if let _pen = deletedPen.object { _pen.tool = nil object.pens.remove(_pen) - withPersistence(\.viewContext) { context in + withPersistence(\.viewContext) { [weak object] context in context.delete(_pen) + object?.memo?.updatedAt = .now try context.saveIfNeeded() } } diff --git a/Memola/Canvas/Tool/Pen/Core/Pen.swift b/Memola/Canvas/Tool/Pen/Core/Pen.swift index 81f461a..9195a6c 100644 --- a/Memola/Canvas/Tool/Pen/Core/Pen.swift +++ b/Memola/Canvas/Tool/Pen/Core/Pen.swift @@ -16,21 +16,25 @@ class Pen: NSObject, ObservableObject, Identifiable { @Published var style: any PenStyle { didSet { object?.style = strokeStyle.rawValue + object?.tool?.memo?.updatedAt = .now } } @Published var rgba: [CGFloat] { didSet { object?.color = rgba + object?.tool?.memo?.updatedAt = .now } } @Published var thickness: CGFloat { didSet { object?.thickness = thickness + object?.tool?.memo?.updatedAt = .now } } @Published var isSelected: Bool { didSet { object?.isSelected = isSelected + object?.tool?.memo?.updatedAt = .now } } var color: Color { diff --git a/Memola/Canvas/Tool/Pen/Core/PenStyle.swift b/Memola/Canvas/Tool/Pen/Core/PenStyle.swift index 8bf961a..6a9edbd 100644 --- a/Memola/Canvas/Tool/Pen/Core/PenStyle.swift +++ b/Memola/Canvas/Tool/Pen/Core/PenStyle.swift @@ -10,6 +10,7 @@ import Foundation protocol PenStyle { var icon: (base: String, tip: String?) { get } + var compactIcon: (base: String, tip: String?) { get } var textureName: String? { get } var thickness: (min: CGFloat, max: CGFloat) { get } var thicknessSteps: [CGFloat] { get } diff --git a/Memola/Canvas/Tool/Pen/PenStyles/EraserPenStyle.swift b/Memola/Canvas/Tool/Pen/PenStyles/EraserPenStyle.swift index f4e96b6..906da73 100644 --- a/Memola/Canvas/Tool/Pen/PenStyles/EraserPenStyle.swift +++ b/Memola/Canvas/Tool/Pen/PenStyles/EraserPenStyle.swift @@ -10,6 +10,8 @@ import Foundation struct EraserPenStyle: PenStyle { var icon: (base: String, tip: String?) = ("eraser", nil) + var compactIcon: (base: String, tip: String?) = ("eraser-compact", nil) + var textureName: String? = nil var thickness: (min: CGFloat, max: CGFloat) = (0.5, 30) diff --git a/Memola/Canvas/Tool/Pen/PenStyles/MarkerPenStyle.swift b/Memola/Canvas/Tool/Pen/PenStyles/MarkerPenStyle.swift index 2e608a4..c171cac 100644 --- a/Memola/Canvas/Tool/Pen/PenStyles/MarkerPenStyle.swift +++ b/Memola/Canvas/Tool/Pen/PenStyles/MarkerPenStyle.swift @@ -10,6 +10,8 @@ import Foundation struct MarkerPenStyle: PenStyle { var icon: (base: String, tip: String?) = ("marker-base", "marker-tip") + var compactIcon: (base: String, tip: String?) = ("marker-base-compact", "marker-tip-compact") + var textureName: String? = "point-texture" var thickness: (min: CGFloat, max: CGFloat) = (0.5, 30) diff --git a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift index 395b096..6ec4d48 100644 --- a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift +++ b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift @@ -259,7 +259,6 @@ extension CanvasViewController: UIScrollViewDelegate { func scrollViewDidZoom(_ scrollView: UIScrollView) { canvas.setZoomScale(scrollView.zoomScale) -// renderer.resize(on: renderView, to: renderView.drawableSize) renderView.draw() } @@ -274,7 +273,6 @@ extension CanvasViewController: UIScrollViewDelegate { } func scrollViewDidScroll(_ scrollView: UIScrollView) { -// renderer.resize(on: renderView, to: renderView.drawableSize) renderView.draw() } diff --git a/Memola/Components/Views/Placeholder/Placeholder.swift b/Memola/Components/Views/Placeholder/Placeholder.swift new file mode 100644 index 0000000..c2d2828 --- /dev/null +++ b/Memola/Components/Views/Placeholder/Placeholder.swift @@ -0,0 +1,73 @@ +// +// Placeholder.swift +// Memola +// +// Created by Dscyre Scotti on 6/27/24. +// + +import SwiftUI + +struct Placeholder: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + let info: Info + + var body: some View { + VStack(spacing: 15) { + let iconSize: CGFloat = horizontalSizeClass == .compact ? 40 : 50 + Image(systemName: info.icon) + .font(.system(size: iconSize)) + .frame(width: iconSize * 1.1, height: iconSize * 1.1) + VStack(spacing: 3) { + Text(info.title) + .font(horizontalSizeClass == .compact ? .headline : .title2) + .fontWeight(.bold) + .foregroundStyle(.primary) + Text(info.description) + .font(horizontalSizeClass == .compact ? .caption : .callout) + .lineLimit(.none) + .fontWeight(.regular) + } + } + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +extension Placeholder { + struct Info { + let title: String + let description: String + let icon: String + + static let memoNotFound: Info = { + let icon: String = "sparkle.magnifyingglass" + let title: String = "Memos Not Found" + let description: String = "There are no memos matching your search.\n Please try different keywords or create a new memo." + return Placeholder.Info(title: title, description: description, icon: icon) + }() + + static let memoEmpty: Info = { + let icon: String = "note.text" + let title: String = "No Memos Available" + let description: String = "You have not created any memos yet.\n Tap the 'New Memo' button to get started." + return Placeholder.Info(title: title, description: description, icon: icon) + }() + + static let trashEmpty: Info = { + let icon: String = "trash" + let title: String = "Trash is Empty" + let description: String = "There are no memos in the trash.\n Deleted memos will appear here." + return Placeholder.Info(title: title, description: description, icon: icon) + }() + + static let trashNotFound: Info = { + let icon: String = "exclamationmark.magnifyingglass" + let title: String = "No Deleted Memos Found" + let description: String = "No memos found in the trash matching your search.\n Try different keywords or check back later." + return Placeholder.Info(title: title, description: description, icon: icon) + }() + } +} diff --git a/Memola/Extensions/Date++.swift b/Memola/Extensions/Date++.swift new file mode 100644 index 0000000..2acaf6e --- /dev/null +++ b/Memola/Extensions/Date++.swift @@ -0,0 +1,32 @@ +// +// Date++.swift +// Memola +// +// Created by Dscyre Scotti on 6/27/24. +// + +import Foundation + +extension Date { + func getTimeDifference(to date: Date) -> String { + let calendar = Calendar.current + + let components = calendar.dateComponents([.minute, .hour, .day, .weekOfYear, .month, .year], from: self, to: date) + + if let years = components.year, years > 0 { + return "\(years) year\(years > 1 ? "s" : "") ago" + } else if let months = components.month, months > 0 { + return "\(months) month\(months > 1 ? "s" : "") ago" + } else if let weeks = components.weekOfYear, weeks > 0 { + return "\(weeks) week\(weeks > 1 ? "s" : "") ago" + } else if let days = components.day, days > 0 { + return "\(days) day\(days > 1 ? "s" : "") ago" + } else if let hours = components.hour, hours > 0 { + return "\(hours) hour\(hours > 1 ? "s" : "") ago" + } else if let minutes = components.minute, minutes > 0 { + return "\(minutes) minute\(minutes > 1 ? "s" : "") ago" + } else { + return "just now" + } + } +} diff --git a/Memola/Features/Dashboard/Dashboard/DashboardView.swift b/Memola/Features/Dashboard/Dashboard/DashboardView.swift new file mode 100644 index 0000000..c6aa549 --- /dev/null +++ b/Memola/Features/Dashboard/Dashboard/DashboardView.swift @@ -0,0 +1,39 @@ +// +// DashboardView.swift +// Memola +// +// Created by Dscyre Scotti on 6/29/24. +// + +import SwiftUI + +struct DashboardView: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + @State var memo: MemoObject? + @State var sidebarItem: SidebarItem? = .memos + + var body: some View { + NavigationSplitView { + Sidebar(sidebarItem: $sidebarItem, horizontalSizeClass: horizontalSizeClass) + } detail: { + switch sidebarItem { + case .memos: + MemosView(memo: $memo) + case .trash: + TrashView(memo: $memo, sidebarItem: $sidebarItem) + default: + MemosView(memo: $memo) + } + } + .fullScreenCover(item: $memo) { memo in + MemoView(memo: memo) + .onDisappear { + withPersistence(\.viewContext) { context in + try context.saveIfNeeded() + context.refreshAllObjects() + } + } + } + } +} diff --git a/Memola/Features/Dashboard/Details/Memos/Filter.swift b/Memola/Features/Dashboard/Details/Memos/Filter.swift new file mode 100644 index 0000000..4a874f5 --- /dev/null +++ b/Memola/Features/Dashboard/Details/Memos/Filter.swift @@ -0,0 +1,26 @@ +// +// Filter.swift +// Memola +// +// Created by Dscyre Scotti on 6/27/24. +// + +import Foundation + +enum Filter: String, Identifiable, Hashable, Equatable { + var id: String { + rawValue + } + + case none + case favorites + + var name: String { + switch self { + case .none: return "All" + case .favorites: return "Favorites" + } + } + + static let all: [Filter] = [.none, .favorites] +} diff --git a/Memola/Features/Dashboard/Details/Memos/MemosView.swift b/Memola/Features/Dashboard/Details/Memos/MemosView.swift new file mode 100644 index 0000000..c931e3b --- /dev/null +++ b/Memola/Features/Dashboard/Details/Memos/MemosView.swift @@ -0,0 +1,252 @@ +// +// MemosView.swift +// Memola +// +// Created by Dscyre Scotti on 5/4/24. +// + +import SwiftUI + +struct MemosView: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + @FetchRequest var memoObjects: FetchedResults + + @State var query: String = "" + @State var currentDate: Date = .now + + @Binding var memo: MemoObject? + + @AppStorage("memola.memo-objects.memos.sort") var sort: Sort = .recent + @AppStorage("memola.memo-objects.memos.filter") var filter: Filter = .none + + let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() + + var placeholder: Placeholder.Info { + query.isEmpty ? .memoEmpty : .memoNotFound + } + + init(memo: Binding) { + _memo = memo + let standard = UserDefaults.standard + var descriptors: [SortDescriptor] = [] + var predicates: [NSPredicate] = [NSPredicate(format: "isTrash = NO")] + let sort = Sort(rawValue: standard.value(forKey: "memola.memo-objects.sort") as? String ?? "") ?? .recent + let filter = Filter(rawValue: standard.value(forKey: "memola.memo-objects.filter") as? String ?? "") ?? .none + if filter == .favorites { + predicates.append(NSPredicate(format: "isFavorite = YES")) + } + descriptors = sort.memoSortDescriptors + let predicate = NSCompoundPredicate(type: .and, subpredicates: predicates) + _memoObjects = FetchRequest(sortDescriptors: descriptors, predicate: predicate) + } + + var body: some View { + MemoGrid(memoObjects: memoObjects, placeholder: placeholder) { memoObject in + memoCard(memoObject) + } + .navigationTitle(horizontalSizeClass == .compact ? "Memos" : "") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $query, placement: .toolbar, prompt: Text("Search")) + .toolbar { + if horizontalSizeClass == .regular { + ToolbarItem(placement: .topBarLeading) { + Text("Memola") + .font(.title3) + .fontWeight(.bold) + } + } + ToolbarItemGroup(placement: .topBarTrailing) { + HStack(spacing: 5) { + Button { + createMemo(title: "Untitled") + } label: { + Image(systemName: "square.and.pencil") + } + .hoverEffect(.lift) + if horizontalSizeClass == .compact { + Menu { + VStack { + Picker("", selection: $sort) { + ForEach(Sort.all) { sort in + Text(sort.name) + .tag(sort) + } + } + .pickerStyle(.automatic) + Picker("", selection: $filter) { + ForEach(Filter.all) { filter in + Text(filter.name) + .tag(filter) + } + } + .pickerStyle(.automatic) + } + } label: { + Image(systemName: "ellipsis.circle") + } + } else { + Menu { + Picker("", selection: $sort) { + ForEach(Sort.all) { sort in + Text(sort.name) + .tag(sort) + } + } + } label: { + Image(systemName: "arrow.up.arrow.down.circle") + } + .hoverEffect(.lift) + Menu { + Picker("", selection: $filter) { + ForEach(Filter.all) { filter in + Text(filter.name) + .tag(filter) + } + } + .pickerStyle(.automatic) + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + } + } + } + } + } + .onChange(of: sort) { oldValue, newValue in + memoObjects.sortDescriptors = newValue.memoSortDescriptors + } + .onChange(of: query) { oldValue, newValue in + updatePredicate() + } + .onChange(of: filter) { oldValue, newValue in + updatePredicate() + } + .onReceive(timer) { date in + currentDate = date + } + .onAppear { + memoObjects.sortDescriptors = sort.memoSortDescriptors + updatePredicate() + } + } + + func memoCard(_ memoObject: MemoObject) -> some View { + MemoCard(memoObject: memoObject) { card in + card + .contextMenu { + Button { + openMemo(for: memoObject) + } label: { + Label("Open", systemImage: "doc.text") + } + Button(role: .destructive) { + markAsTrash(for: memoObject) + } label: { + Label("Delete", systemImage: "trash") + } + } + .overlay(alignment: .topTrailing) { + Image(systemName: memoObject.isFavorite ? "star.fill" : "star") + .contentTransition(.symbolEffect(.replace)) + .foregroundStyle(memoObject.isFavorite ? .yellow : .primary) + .animation(.easeInOut, value: memoObject.isFavorite) + .frame(width: 20, height: 20) + .padding(5) + .background(.gray) + .cornerRadius(5) + .contentShape(Rectangle()) + .onTapGesture { + toggleFavorite(for: memoObject) + } + .padding(5) + } + } details: { + Text("Edited \(memoObject.updatedAt.getTimeDifference(to: currentDate))") + .font(.caption) + .foregroundStyle(.secondary) + } + .onTapGesture { + openMemo(for: memoObject) + } + } + + func createMemo(title: String) { + let memoObject = MemoObject(\.viewContext) + memoObject.title = title + memoObject.createdAt = .now + memoObject.updatedAt = .now + + let canvasObject = CanvasObject(\.viewContext) + canvasObject.width = 8_000 + canvasObject.height = 8_000 + canvasObject.gridMode = 1 + + let toolObject = ToolObject(\.viewContext) + toolObject.selection = 0 + toolObject.pens = [] + + let eraserPenObject = PenObject.createObject(\.viewContext, penStyle: .eraser) + eraserPenObject.orderIndex = 0 + let markerPenObjects = [Color.red, Color.blue, Color.yellow, Color.black].enumerated().map { (index, color) in + let penObject = PenObject.createObject(\.viewContext, penStyle: .marker) + penObject.orderIndex = Int16(index) + 1 + penObject.color = color.components + return penObject + } + markerPenObjects.first?.isSelected = true + + let graphicContextObject = GraphicContextObject(\.viewContext) + graphicContextObject.elements = [] + + memoObject.canvas = canvasObject + memoObject.tool = toolObject + + canvasObject.memo = memoObject + canvasObject.graphicContext = graphicContextObject + + toolObject.memo = memoObject + toolObject.pens = .init(array: [eraserPenObject] + markerPenObjects) + + eraserPenObject.tool = toolObject + markerPenObjects.forEach { $0.tool = toolObject } + + graphicContextObject.canvas = canvasObject + + withPersistenceSync(\.viewContext) { context in + try context.save() + DispatchQueue.main.async { + openMemo(for: memoObject) + } + } + } + + func openMemo(for memo: MemoObject) { + self.memo = memo + } + + func updatePredicate() { + var predicates: [NSPredicate] = [NSPredicate(format: "isTrash = NO")] + if !query.isEmpty { + predicates.append(NSPredicate(format: "title contains[c] %@", query)) + } + if filter == .favorites { + predicates.append(NSPredicate(format: "isFavorite = YES")) + } + memoObjects.nsPredicate = NSCompoundPredicate(type: .and, subpredicates: predicates) + } + + func toggleFavorite(for memo: MemoObject) { + memo.isFavorite.toggle() + withPersistence(\.viewContext) { context in + try context.saveIfNeeded() + } + } + + func markAsTrash(for memo: MemoObject) { + memo.isTrash = true + memo.deletedAt = .now + withPersistence(\.viewContext) { context in + try context.saveIfNeeded() + } + } +} diff --git a/Memola/Features/Dashboard/Details/Memos/Sort.swift b/Memola/Features/Dashboard/Details/Memos/Sort.swift new file mode 100644 index 0000000..ddd4611 --- /dev/null +++ b/Memola/Features/Dashboard/Details/Memos/Sort.swift @@ -0,0 +1,64 @@ +// +// Sort.swift +// Memola +// +// Created by Dscyre Scotti on 6/27/24. +// + +import Foundation + +enum Sort: String, Identifiable, Hashable, Equatable { + var id: String { + rawValue + } + + case recent + case aToZ + case zToA + case newest + case oldest + + var name: String { + switch self { + case .recent: return "Recent" + case .aToZ: return "A to Z" + case .zToA: return "Z to A" + case .newest: return "Newest" + case .oldest: return "Oldest" + } + } + + static let all: [Sort] = [.recent, .aToZ, .zToA, .newest, .oldest] +} + +extension Sort { + var memoSortDescriptors: [SortDescriptor] { + switch self { + case .recent: + return [SortDescriptor(\.updatedAt, order: .reverse)] + case .aToZ: + return [SortDescriptor(\.title), SortDescriptor(\.updatedAt, order: .reverse)] + case .zToA: + return [SortDescriptor(\.title, order: .reverse), SortDescriptor(\.updatedAt, order: .reverse)] + case .newest: + return [SortDescriptor(\.createdAt, order: .reverse)] + case .oldest: + return [SortDescriptor(\.createdAt)] + } + } + + var trashSortDescriptors: [SortDescriptor] { + switch self { + case .recent: + return [SortDescriptor(\.updatedAt, order: .reverse)] + case .aToZ: + return [SortDescriptor(\.title), SortDescriptor(\.updatedAt, order: .reverse)] + case .zToA: + return [SortDescriptor(\.title, order: .reverse), SortDescriptor(\.updatedAt, order: .reverse)] + case .newest: + return [SortDescriptor(\.createdAt, order: .reverse)] + case .oldest: + return [SortDescriptor(\.createdAt)] + } + } +} diff --git a/Memola/Features/Dashboard/Details/Shared/MemoCard.swift b/Memola/Features/Dashboard/Details/Shared/MemoCard.swift new file mode 100644 index 0000000..fb24331 --- /dev/null +++ b/Memola/Features/Dashboard/Details/Shared/MemoCard.swift @@ -0,0 +1,37 @@ +// +// MemoCard.swift +// Memola +// +// Created by Dscyre Scotti on 6/29/24. +// + +import SwiftUI + +struct MemoCard: View { + let memoObject: MemoObject + let modifyPreview: ((MemoPreview) -> Preview)? + let details: () -> Detail + + init(memoObject: MemoObject, @ViewBuilder modifyPreview: @escaping (MemoPreview) -> Preview, @ViewBuilder details: @escaping () -> Detail) { + self.memoObject = memoObject + self.modifyPreview = modifyPreview + self.details = details + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + if let modifyPreview { + modifyPreview(MemoPreview()) + } else { + MemoPreview() + } + VStack(alignment: .leading, spacing: 2) { + Text(memoObject.title) + .font(.headline) + .lineLimit(1) + .truncationMode(.tail) + details() + } + } + } +} diff --git a/Memola/Features/Dashboard/Details/Shared/MemoGrid.swift b/Memola/Features/Dashboard/Details/Shared/MemoGrid.swift new file mode 100644 index 0000000..e443f53 --- /dev/null +++ b/Memola/Features/Dashboard/Details/Shared/MemoGrid.swift @@ -0,0 +1,41 @@ +// +// MemoGrid.swift +// Memola +// +// Created by Dscyre Scotti on 6/29/24. +// + +import SwiftUI + +struct MemoGrid: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + let memoObjects: FetchedResults + let placeholder: Placeholder.Info + @ViewBuilder let card: (MemoObject) -> Card + + var cellWidth: CGFloat { + if horizontalSizeClass == .compact { + return 180 + } + return 250 + } + + var body: some View { + if memoObjects.isEmpty { + Placeholder(info: placeholder) + } else { + GeometryReader { proxy in + let count = Int(proxy.size.width / cellWidth) + let columns: [GridItem] = .init(repeating: GridItem(.flexible(), spacing: 15), count: count) + ScrollView { + LazyVGrid(columns: columns, spacing: 15) { + ForEach(memoObjects) { memoObject in + card(memoObject) + } + } + .padding() + } + } + } + } +} diff --git a/Memola/Features/Dashboard/Details/Shared/MemoPreview.swift b/Memola/Features/Dashboard/Details/Shared/MemoPreview.swift new file mode 100644 index 0000000..6ec7f01 --- /dev/null +++ b/Memola/Features/Dashboard/Details/Shared/MemoPreview.swift @@ -0,0 +1,25 @@ +// +// MemoPreview.swift +// Memola +// +// Created by Dscyre Scotti on 6/29/24. +// + +import SwiftUI + +struct MemoPreview: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + var cellHeight: CGFloat { + if horizontalSizeClass == .compact { + return 120 + } + return 150 + } + + var body: some View { + Rectangle() + .frame(height: cellHeight) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} diff --git a/Memola/Features/Dashboard/Details/Trash/TrashView.swift b/Memola/Features/Dashboard/Details/Trash/TrashView.swift new file mode 100644 index 0000000..5deff1a --- /dev/null +++ b/Memola/Features/Dashboard/Details/Trash/TrashView.swift @@ -0,0 +1,147 @@ +// +// TrashView.swift +// Memola +// +// Created by Dscyre Scotti on 6/29/24. +// + +import SwiftUI + +struct TrashView: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + @FetchRequest var memoObjects: FetchedResults + + @State var query: String = "" + @State var restoredMemo: MemoObject? + @State var deletedMemo: MemoObject? + + @Binding var memo: MemoObject? + @Binding var sidebarItem: SidebarItem? + + var placeholder: Placeholder.Info { + query.isEmpty ? .trashEmpty : .trashNotFound + } + + init(memo: Binding, sidebarItem: Binding) { + _memo = memo + _sidebarItem = sidebarItem + let descriptors = [SortDescriptor(\MemoObject.deletedAt, order: .reverse)] + let predicate = NSPredicate(format: "isTrash = YES") + _memoObjects = FetchRequest(sortDescriptors: descriptors, predicate: predicate) + } + + var body: some View { + let restoresMemo = Binding { + restoredMemo != nil + } set: { _ in + restoredMemo = nil + } + let deletesMemo = Binding { + deletedMemo != nil + } set: { _ in + deletedMemo = nil + } + MemoGrid(memoObjects: memoObjects, placeholder: placeholder) { memoObject in + memoCard(memoObject) + } + .navigationTitle(horizontalSizeClass == .compact ? "Trash" : "") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $query, placement: .toolbar, prompt: Text("Search")) + .toolbar { + if horizontalSizeClass == .regular { + ToolbarItem(placement: .topBarLeading) { + Text("Memola") + .font(.title3) + .fontWeight(.bold) + } + } + } + .onChange(of: query) { oldValue, newValue in + updatePredicate() + } + .alert("Restore Memo", isPresented: restoresMemo) { + Button { + restoreMemo(for: restoredMemo) + } label: { + Text("Restore") + } + Button { + restoreAndOpenMemo(for: restoredMemo) + } label: { + Text("Restore and Open") + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Would you like to restore this memo or restore and open it?") + } + .alert("Delete Memo Permanently", isPresented: deletesMemo) { + Button(role: .destructive) { + deleteMemo(for: deletedMemo) + } label: { + Text("Delete") + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Are you sure you want to permanently delete this memo? This action cannot be undone.") + } + } + + func memoCard(_ memoObject: MemoObject) -> some View { + MemoCard(memoObject: memoObject) { card in + card + .contextMenu { + Button { + restoreMemo(for: memoObject) + } label: { + Label("Restore", systemImage: "square.and.arrow.down") + } + Button(role: .destructive) { + deletedMemo = memoObject + } label: { + Label("Delete Permanently", systemImage: "trash") + } + } + } details: { + if let deletedAt = memoObject.deletedAt { + Text("Deleted on \(deletedAt.formatted(date: .abbreviated, time: .standard))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .onTapGesture { + restoredMemo = memoObject + } + } + + func updatePredicate() { + var predicates: [NSPredicate] = [NSPredicate(format: "isTrash = YES")] + if !query.isEmpty { + predicates.append(NSPredicate(format: "title contains[c] %@", query)) + } + memoObjects.nsPredicate = NSCompoundPredicate(type: .and, subpredicates: predicates) + } + + func restoreMemo(for memo: MemoObject?) { + guard let memo else { return } + memo.isTrash = false + memo.deletedAt = nil + withPersistence(\.viewContext) { context in + try context.saveIfNeeded() + } + } + + func restoreAndOpenMemo(for memo: MemoObject?) { + restoreMemo(for: memo) + self.sidebarItem = .memos + self.memo = memo + } + + func deleteMemo(for memo: MemoObject?) { + guard let memo else { return } + withPersistenceSync(\.viewContext) { context in + context.delete(memo) + try context.saveIfNeeded() + } + } +} diff --git a/Memola/Features/Dashboard/Sidebar/Sidebar.swift b/Memola/Features/Dashboard/Sidebar/Sidebar.swift new file mode 100644 index 0000000..99ba758 --- /dev/null +++ b/Memola/Features/Dashboard/Sidebar/Sidebar.swift @@ -0,0 +1,87 @@ +// +// Sidebar.swift +// Memola +// +// Created by Dscyre Scotti on 6/29/24. +// + +import SwiftUI + +struct Sidebar: View { + let sidebarItems: [SidebarItem] = [.memos, .trash] + @Binding var sidebarItem: SidebarItem? + + let horizontalSizeClass: UserInterfaceSizeClass? + + var body: some View { + List(selection: $sidebarItem) { + ForEach(sidebarItems) { item in + if horizontalSizeClass == .compact { + Button { + sidebarItem = item + } label: { + Label(item.title, systemImage: item.icon) + .foregroundColor(.primary) + } + } else { + Button { + sidebarItem = item + } label: { + Label(item.title, systemImage: item.icon) + .foregroundColor(.primary) + } + .buttonStyle(sidebarItem == item ? .selected : .unselected) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + } + } + .listStyle(.sidebar) + .navigationTitle(horizontalSizeClass == .compact ? "Memola" : "") + .scrollContentBackground(.hidden) + .background(Color(uiColor: .secondarySystemFill)) + .navigationSplitViewColumnWidth(min: 250, ideal: 250, max: 250) + .navigationBarTitleDisplayMode(horizontalSizeClass == .compact ? .automatic : .inline) + } +} + +extension Sidebar { + struct SidebarItemButtonStyle: ButtonStyle { + let state: State + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(maxWidth: .infinity, alignment: .leading) + #if os(macOS) + .padding(10) + #else + .padding(.vertical, 8) + .padding(.horizontal, 10) + #endif + .contentShape(RoundedRectangle(cornerRadius: 10)) + .background { + switch state { + case .selected: + RoundedRectangle(cornerRadius: 10) + .fill(.primary.opacity(0.08)) + case .unselected: + EmptyView() + } + } + } + + enum State { + case selected + case unselected + } + } +} + +extension ButtonStyle where Self == Sidebar.SidebarItemButtonStyle { + static var selected: Sidebar.SidebarItemButtonStyle { + Sidebar.SidebarItemButtonStyle(state: .selected) + } + + static var unselected: Sidebar.SidebarItemButtonStyle { + Sidebar.SidebarItemButtonStyle(state: .unselected) + } +} diff --git a/Memola/Features/Dashboard/Sidebar/SidebarItem.swift b/Memola/Features/Dashboard/Sidebar/SidebarItem.swift new file mode 100644 index 0000000..7c62cad --- /dev/null +++ b/Memola/Features/Dashboard/Sidebar/SidebarItem.swift @@ -0,0 +1,35 @@ +// +// SidebarItem.swift +// Memola +// +// Created by Dscyre Scotti on 6/29/24. +// + +import Foundation + +enum SidebarItem: String, Identifiable, Hashable, Equatable { + var id: String { rawValue } + + case memos + case trash + + var title: String { + switch self { + case .memos: + "Memos" + case .trash: + "Trash" + } + } + + var icon: String { + switch self { + case .memos: + "rectangle.3.group" + case .trash: + "trash" + } + } + + static let all: [SidebarItem] = [.memos, .trash] +} diff --git a/Memola/Features/Memo/ElementToolbar/ElementToolbar.swift b/Memola/Features/Memo/ElementToolbar/ElementToolbar.swift new file mode 100644 index 0000000..d3c62a3 --- /dev/null +++ b/Memola/Features/Memo/ElementToolbar/ElementToolbar.swift @@ -0,0 +1,244 @@ +// +// ElementToolbar.swift +// Memola +// +// Created by Dscyre Scotti on 6/30/24. +// + +import SwiftUI +import PhotosUI +import AVFoundation + +struct ElementToolbar: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + let size: CGFloat + @ObservedObject var tool: Tool + @ObservedObject var canvas: Canvas + + @State var opensCamera: Bool = false + @State var isCameraAccessDenied: Bool = false + @State var photosPickerItem: PhotosPickerItem? + + @Namespace var namespace + + var body: some View { + Group { + if horizontalSizeClass == .regular { + regularToolbar + } else { + ZStack(alignment: .bottom) { + if tool.selection == .photo { + photoOption + .background { + RoundedRectangle(cornerRadius: 8) + .fill(.regularMaterial) + } + .padding(.bottom, 10) + .frame(maxWidth: .infinity) + .transition(.move(edge: .bottom).combined(with: .blurReplace)) + } else { + compactToolbar + } + } + .padding(.bottom, 10) + } + } + .fullScreenCover(isPresented: $opensCamera) { + let image: Binding = Binding { + tool.selectedPhotoItem?.image + } set: { image in + guard let image else { return } + tool.selectPhoto(image, for: canvas.canvasID) + } + CameraView(image: image, canvas: canvas) + .ignoresSafeArea() + } + .alert("Camera Access Denied", isPresented: $isCameraAccessDenied) { + Button { + if let url = URL(string: UIApplication.openSettingsURLString + "&path=CAMERA/\(String(describing: Bundle.main.bundleIdentifier))") { + UIApplication.shared.open(url) + } + } label: { + Text("Open Settings") + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Memola requires access to the camera to capture photos. Please open Settings and enable camera access.") + } + .onChange(of: photosPickerItem) { oldValue, newValue in + if newValue != nil { + Task { + tool.isLoadingPhoto = true + let data = try? await newValue?.loadTransferable(type: Data.self) + if let data, let image = UIImage(data: data) { + tool.selectPhoto(image, for: canvas.canvasID) + } + photosPickerItem = nil + } + } + } + } + + var regularToolbar: some View { + HStack(spacing: 0) { + Button { + withAnimation { + tool.selectTool(.hand) + } + } label: { + Image(systemName: "hand.draw.fill") + .fontWeight(.heavy) + .contentShape(.circle) + .frame(width: size, height: size) + .foregroundStyle(tool.selection == .hand ? Color.white : Color.accentColor) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + .background { + if tool.selection == .hand { + Color.accentColor + .clipShape(.rect(cornerRadius: 8)) + .matchedGeometryEffect(id: "element.toolbar.bg", in: namespace) + } + } + Button { + withAnimation { + tool.selectTool(.pen) + } + } label: { + Image(systemName: "pencil") + .fontWeight(.heavy) + .contentShape(.circle) + .frame(width: size, height: size) + .foregroundStyle(tool.selection == .pen ? Color.white : Color.accentColor) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + .background { + if tool.selection == .pen { + Color.accentColor + .clipShape(.rect(cornerRadius: 8)) + .matchedGeometryEffect(id: "element.toolbar.bg", in: namespace) + } + } + HStack(spacing: 0) { + Button { + withAnimation { + tool.selectTool(.photo) + } + } label: { + Image(systemName: "photo") + .contentShape(.circle) + .frame(width: size, height: size) + .foregroundStyle(tool.selection == .photo ? Color.white : Color.accentColor) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + .background { + if tool.selection == .photo { + Color.accentColor + .clipShape(.rect(cornerRadius: 8)) + .matchedGeometryEffect(id: "element.toolbar.bg", in: namespace) + } + if tool.selection != .photo { + Color.clear + .matchedGeometryEffect(id: "element.toolbar.photo.options", in: namespace) + } + } + if tool.selection == .photo { + photoOption + .matchedGeometryEffect(id: "element.toolbar.photo.options", in: namespace) + .transition(.blurReplace.animation(.easeIn(duration: 0.1))) + } + } + .background { + if tool.selection == .photo { + RoundedRectangle(cornerRadius: 8) + .fill(Color.white.tertiary) + .transition(.move(edge: .leading).combined(with: .opacity).animation(.easeIn(duration: 0.1))) + } + } + } + .background { + RoundedRectangle(cornerRadius: 8) + .fill(.regularMaterial) + } + .transition(.move(edge: .top).combined(with: .blurReplace)) + } + + var compactToolbar: some View { + HStack(spacing: 0) { + Button { + withAnimation { + tool.selectTool(.pen) + } + } label: { + Image(systemName: "pencil") + .fontWeight(.heavy) + .contentShape(.circle) + .frame(width: size, height: size) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + Button { + withAnimation { + tool.selectTool(.photo) + } + } label: { + Image(systemName: "photo") + .contentShape(.circle) + .frame(width: size, height: size) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + } + .background { + RoundedRectangle(cornerRadius: 8) + .fill(.regularMaterial) + } + .padding(10) + .frame(maxWidth: .infinity) + .transition(.move(edge: .bottom).combined(with: .blurReplace)) + } + + var photoOption: some View { + HStack(spacing: 0) { + Button { + openCamera() + } label: { + Image(systemName: "camera.fill") + .contentShape(.circle) + .frame(width: size, height: size) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + PhotosPicker(selection: $photosPickerItem, matching: .images, preferredItemEncoding: .compatible) { + Image(systemName: "photo.fill.on.rectangle.fill") + .contentShape(.circle) + .frame(width: size, height: size) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + } + } + + func openCamera() { + let status = AVCaptureDevice.authorizationStatus(for: .video) + switch status { + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { status in + withAnimation { + if status { + opensCamera = true + } else { + isCameraAccessDenied = true + } + } + } + case .authorized: + opensCamera = true + default: + isCameraAccessDenied = true + } + } +} diff --git a/Memola/Features/Memo/Memo/MemoView.swift b/Memola/Features/Memo/Memo/MemoView.swift index afc3b56..722e431 100644 --- a/Memola/Features/Memo/Memo/MemoView.swift +++ b/Memola/Features/Memo/Memo/MemoView.swift @@ -9,9 +9,11 @@ import SwiftUI import CoreData struct MemoView: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @StateObject var tool: Tool @StateObject var canvas: Canvas - @StateObject var history = History() + @StateObject var history: History @State var memo: MemoObject @State var title: String @@ -24,20 +26,51 @@ struct MemoView: View { self.title = memo.title self._tool = StateObject(wrappedValue: Tool(object: memo.tool)) self._canvas = StateObject(wrappedValue: Canvas(size: memo.canvas.size, canvasID: memo.canvas.objectID, gridMode: memo.canvas.gridMode)) + self._history = StateObject(wrappedValue: History(memo: memo)) } var body: some View { + Group { + if horizontalSizeClass == .regular { + canvasView + } else { + compactCanvasView + } + } + .overlay(alignment: .top) { + Toolbar(size: size, memo: memo, tool: tool, canvas: canvas, history: history) + } + .disabled(textFieldState || tool.isLoadingPhoto) + .disabled(canvas.state == .loading || canvas.state == .closing) + .overlay { + switch canvas.state { + case .loading: + loadingIndicator("Loading memo...") + case .closing: + loadingIndicator("Saving memo...") + default: + EmptyView() + } + } + .overlay { + if tool.isLoadingPhoto { + loadingIndicator("Loading photo...") + } + } + } + + var canvasView: some View { CanvasView(tool: tool, canvas: canvas, history: history) .ignoresSafeArea() .overlay(alignment: .bottomTrailing) { switch tool.selection { case .pen: PenDock(tool: tool, canvas: canvas, size: size) - .transition(.move(edge: .trailing)) + .transition(.move(edge: .trailing).combined(with: .blurReplace)) case .photo: if let photoItem = tool.selectedPhotoItem { PhotoPreview(photoItem: photoItem, tool: tool) - .transition(.move(edge: .trailing)) + .transition(.move(edge: .trailing).combined(with: .blurReplace)) } default: EmptyView() @@ -46,24 +79,48 @@ struct MemoView: View { .overlay(alignment: .bottomLeading) { zoomControl } - .disabled(textFieldState || tool.isLoadingPhoto) - .overlay(alignment: .top) { - Toolbar(size: size, memo: memo, tool: tool, canvas: canvas, history: history) - } - .disabled(canvas.state == .loading || canvas.state == .closing) - .overlay { - switch canvas.state { - case .loading: - loadingIndicator("Loading memo...") - case .closing: - loadingIndicator("Saving memo...") + } + + var compactCanvasView: some View { + CanvasView(tool: tool, canvas: canvas, history: history) + .ignoresSafeArea() + .overlay(alignment: .bottom) { + switch tool.selection { + case .pen: + PenDock(tool: tool, canvas: canvas, size: size) + .transition(.move(edge: .bottom).combined(with: .blurReplace)) + case .photo: + if let photoItem = tool.selectedPhotoItem { + PhotoPreview(photoItem: photoItem, tool: tool) + .frame(maxWidth: .infinity, alignment: .trailing) + .transition(.move(edge: .trailing)) + } default: EmptyView() } } - .overlay { - if tool.isLoadingPhoto { - loadingIndicator("Loading photo...") + .overlay(alignment: .bottom) { + if tool.selection != .pen { + ElementToolbar(size: size, tool: tool, canvas: canvas) + .transition(.move(edge: .bottom).combined(with: .blurReplace)) + } + } + .overlay(alignment: .bottom) { + if tool.selection != .hand { + Button { + withAnimation { + tool.selectTool(.hand) + } + } label: { + Image(systemName: "chevron.compact.down") + .font(.headline) + .frame(width: 80) + .padding(5) + .background(.regularMaterial) + .clipShape(.capsule) + .contentShape(.capsule) + } + .offset(y: 5) } } } diff --git a/Memola/Features/Memo/PenDock/PenDock.swift b/Memola/Features/Memo/PenDock/PenDock.swift index d9af8ca..8624221 100644 --- a/Memola/Features/Memo/PenDock/PenDock.swift +++ b/Memola/Features/Memo/PenDock/PenDock.swift @@ -8,41 +8,82 @@ import SwiftUI struct PenDock: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass @ObservedObject var tool: Tool @ObservedObject var canvas: Canvas let size: CGFloat - let width: CGFloat = 90 - let height: CGFloat = 30 - let factor: CGFloat = 0.9 + var width: CGFloat { + horizontalSizeClass == .compact ? 30 : 90 + } + var height: CGFloat { + horizontalSizeClass == .compact ? 90 : 30 + } + var factor: CGFloat { + horizontalSizeClass == .compact ? 0.9 : 0.9 + } @State var refreshScrollId: UUID = UUID() @State var opensColorPicker: Bool = false var body: some View { - ZStack(alignment: .bottomTrailing) { - if !canvas.locksCanvas { - VStack(alignment: .trailing) { - penPropertyTool - penItemList + if horizontalSizeClass == .regular { + ZStack(alignment: .bottomTrailing) { + if !canvas.locksCanvas { + VStack(alignment: .trailing) { + penPropertyTool + penItemList + } + .fixedSize() + .frame(maxHeight: .infinity) + .padding(10) + .transition(.move(edge: .trailing).combined(with: .blurReplace)) } - .fixedSize() - .frame(maxHeight: .infinity) - .padding(10) - .transition(.move(edge: .trailing).combined(with: .blurReplace)) + lockButton + .padding(10) + .transition(.move(edge: .trailing).combined(with: .blurReplace)) + } + } else { + ZStack(alignment: .bottomTrailing) { + if !canvas.locksCanvas { + GeometryReader { proxy in + HStack(alignment: .bottom, spacing: 10) { + newPenButton + .frame(height: height * factor - 18) + compactPenItemList + .fixedSize(horizontal: false, vertical: true) + compactPenPropertyTool + } + .padding(.horizontal, 10) + .clipped() + .background(alignment: .bottom) { + RoundedRectangle(cornerRadius: 8) + .fill(.regularMaterial) + .frame(height: height * factor - 18) + } + .padding(.horizontal, 10) + .padding(.bottom, 20) + .frame(maxWidth: min(proxy.size.height, proxy.size.width), maxHeight: .infinity, alignment: .bottom) + .frame(maxWidth: .infinity) + } + .transition(.move(edge: .bottom).combined(with: .blurReplace)) + } + lockButton + .frame(maxWidth: .infinity, alignment: .bottomTrailing) + .padding(10) + .offset(y: canvas.locksCanvas ? 0 : -(height * factor - size + 30)) + .transition(.move(edge: .trailing).combined(with: .blurReplace)) } - lockButton - .padding(10) - .transition(.move(edge: .trailing).combined(with: .blurReplace)) } } + @ViewBuilder var penItemList: some View { ScrollViewReader { proxy in ScrollView(.vertical, showsIndicators: false) { LazyVStack(spacing: 0) { ForEach(tool.pens) { pen in - penItemRow(pen) + penItem(pen) .id(pen.id) .scrollTransition { content, phase in content @@ -75,7 +116,34 @@ struct PenDock: View { } } - func penItemRow(_ pen: Pen) -> some View { + @ViewBuilder + var compactPenItemList: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 0) { + ForEach(tool.pens) { pen in + compactPenItem(pen) + .id(pen.id) + .scrollTransition { content, phase in + content + .scaleEffect(phase.isIdentity ? 1 : 0.04, anchor: .bottom) + } + } + } + .padding(.horizontal, 10) + .id(refreshScrollId) + } + .onReceive(tool.scrollPublisher) { id in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation { + proxy.scrollTo(id) + } + } + } + } + } + + func penItem(_ pen: Pen) -> some View { ZStack { penShadow(pen) if let tip = pen.style.icon.tip { @@ -88,7 +156,7 @@ struct PenDock: View { .resizable() } .frame(width: width * factor, height: height * factor) - .padding(.vertical, 5) + .padding(.horizontal, 5) .contentShape(.rect(cornerRadii: .init(topLeading: 10, bottomLeading: 10))) .onTapGesture { if tool.selectedPen !== pen { @@ -148,6 +216,79 @@ struct PenDock: View { .offset(x: tool.selectedPen === pen ? 0 : 25) } + func compactPenItem(_ pen: Pen) -> some View { + ZStack { + compactPenShadow(pen) + if let tip = pen.style.compactIcon.tip { + Image(tip) + .resizable() + .renderingMode(.template) + .foregroundStyle(Color.rgba(from: pen.rgba)) + } + Image(pen.style.compactIcon.base) + .resizable() + } + .frame(width: width * factor, height: height * factor) + .padding(.top, 5) + .contentShape(.rect(cornerRadii: .init(topLeading: 10, bottomLeading: 10))) + .onTapGesture { + if tool.selectedPen !== pen { + tool.selectPen(pen) + } + } + .padding(.horizontal, 10) + .contextMenu(if: pen.strokeStyle != .eraser) { + ControlGroup { + Button { + tool.selectPen(pen) + } label: { + Label( + title: { Text("Select") }, + icon: { Image(systemName: "pencil.tip.crop.circle") } + ) + } + Button { + let originalPen = pen + let pen = PenObject.createObject(\.viewContext, penStyle: originalPen.style) + pen.color = originalPen.rgba + pen.thickness = originalPen.thickness + pen.isSelected = true + pen.tool = tool.object + let _pen = Pen(object: pen) + tool.duplicatePen(_pen, of: originalPen) + } label: { + Label( + title: { Text("Duplicate") }, + icon: { Image(systemName: "plus.square.on.square") } + ) + } + Button(role: .destructive) { + tool.removePen(pen) + } label: { + Label( + title: { Text("Remove") }, + icon: { Image(systemName: "trash") } + ) + } + .disabled(tool.markers.count <= 1) + } + .controlGroupStyle(.menu) + } preview: { + penPreview(pen) + .drawingGroup() + .contentShape(.contextMenuPreview, .rect(cornerRadius: 10)) + } + .onDrag(if: pen.strokeStyle != .eraser) { + tool.draggedPen = pen + return NSItemProvider(contentsOf: URL(string: pen.id)) ?? NSItemProvider() + } preview: { + penPreview(pen) + .contentShape(.dragPreview, .rect(cornerRadius: 10)) + } + .onDrop(of: [.item], delegate: PenDropDelegate(id: pen.id, tool: tool, action: { refreshScrollId = UUID() })) + .offset(y: tool.selectedPen === pen ? 0 : 25) + } + @ViewBuilder var penPropertyTool: some View { if let pen = tool.selectedPen { @@ -170,6 +311,23 @@ struct PenDock: View { } } + @ViewBuilder + var compactPenPropertyTool: some View { + if let pen = tool.selectedPen { + HStack(spacing: 10) { + compactPenThicknessPicker(pen) + .frame(width: width) + .rotationEffect(.degrees(-90)) + if pen.strokeStyle == .marker { + penColorPicker(pen) + .frame(width: width) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .frame(height: height * factor - 18) + } + } + func penColorPicker(_ pen: Pen) -> some View { Button { opensColorPicker = true @@ -191,12 +349,13 @@ struct PenDock: View { } .background(baseColor) .clipShape(.rect(cornerRadius: 8)) - .frame(height: 25) + .frame(height: horizontalSizeClass == .compact ? 30 : 25) .overlay { RoundedRectangle(cornerRadius: 8) .stroke(Color.gray, lineWidth: 0.4) } .padding(0.2) + .drawingGroup() } .buttonStyle(.plain) .hoverEffect(.lift) @@ -250,19 +409,41 @@ struct PenDock: View { } } + @ViewBuilder + func compactPenThicknessPicker(_ pen: Pen) -> some View { + let minimum: CGFloat = pen.style.thickness.min + let maximum: CGFloat = pen.style.thickness.max + let start: CGFloat = 4 + let end: CGFloat = 7 + let selection = Binding( + get: { pen.thickness }, + set: { + pen.thickness = $0 + tool.objectWillChange.send() + } + ) + Picker("", selection: selection) { + ForEach(pen.style.thicknessSteps, id: \.self) { step in + let size = ((step - minimum) * (end - start) / (maximum - minimum)) + start - (0.5 / step) + Circle() + .fill(.black) + .frame(width: size, height: size) + .frame(width: size + 2, height: size + 2) + } + } + .hoverEffect(.lift) + .pickerStyle(.wheel) + .frame(width: 50, height: 30) + .onChange(of: pen.thickness) { _, _ in + withPersistence(\.viewContext) { context in + try context.saveIfNeeded() + } + } + } + var newPenButton: some View { Button { - let pen = PenObject.createObject(\.viewContext, penStyle: .marker) - var selectedPen = tool.selectedPen - selectedPen = (selectedPen?.strokeStyle == .marker ? (selectedPen ?? tool.pens.last) : tool.pens.last) - if let color = selectedPen?.rgba { - pen.color = color - } - pen.isSelected = true - pen.tool = tool.object - pen.orderIndex = Int16(tool.pens.count) - let _pen = Pen(object: pen) - tool.addPen(_pen) + createNewPen() } label: { Image(systemName: "plus.circle.fill") .font(.title2) @@ -317,6 +498,30 @@ struct PenDock: View { } } + func compactPenShadow(_ pen: Pen) -> some View { + ZStack { + Group { + if let tip = pen.style.compactIcon.tip { + Image(tip) + .resizable() + .renderingMode(.template) + } + Image(pen.style.compactIcon.base) + .resizable() + .renderingMode(.template) + } + .foregroundStyle(.black.opacity(0.2)) + .blur(radius: 3) + if let tip = pen.style.compactIcon.tip { + Image(tip) + .resizable() + .renderingMode(.template) + .foregroundStyle(Color(red: pen.rgba[0], green: pen.rgba[1], blue: pen.rgba[2])) + .blur(radius: 0.5) + } + } + } + var lockButton: some View { Button { withAnimation { @@ -332,4 +537,18 @@ struct PenDock: View { .hoverEffect(.lift) .contentTransition(.symbolEffect(.replace)) } + + func createNewPen() { + let pen = PenObject.createObject(\.viewContext, penStyle: .marker) + var selectedPen = tool.selectedPen + selectedPen = (selectedPen?.strokeStyle == .marker ? (selectedPen ?? tool.pens.last) : tool.pens.last) + if let color = selectedPen?.rgba { + pen.color = color + } + pen.isSelected = true + pen.tool = tool.object + pen.orderIndex = Int16(tool.pens.count) + let _pen = Pen(object: pen) + tool.addPen(_pen) + } } diff --git a/Memola/Features/Memo/PenDock/PenDropDelegate.swift b/Memola/Features/Memo/PenDock/PenDropDelegate.swift index 7d91630..8557f34 100644 --- a/Memola/Features/Memo/PenDock/PenDropDelegate.swift +++ b/Memola/Features/Memo/PenDock/PenDropDelegate.swift @@ -29,10 +29,11 @@ struct PenDropDelegate: DropDelegate { tool.pens.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: toIndex > fromIndex ? toIndex + 1 : toIndex) tool.objectWillChange.send() } - withPersistence(\.viewContext) { context in + withPersistence(\.viewContext) { [weak object = tool.object] context in for (index, pen) in tool.pens.enumerated() { pen.object?.orderIndex = Int16(index) } + object?.memo?.updatedAt = .now try context.saveIfNeeded() } } diff --git a/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift b/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift index 16e7399..beb923e 100644 --- a/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift +++ b/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift @@ -8,6 +8,8 @@ import SwiftUI struct PhotoPreview: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + let photoItem: PhotoItem @ObservedObject var tool: Tool @@ -15,7 +17,7 @@ struct PhotoPreview: View { Image(uiImage: photoItem.previewImage) .resizable() .scaledToFit() - .frame(height: 100) + .frame(width: horizontalSizeClass == .compact ? 80 : nil, height: horizontalSizeClass == .compact ? nil : 100) .cornerRadius(5) .overlay { RoundedRectangle(cornerRadius: 5) diff --git a/Memola/Features/Memo/Toolbar/Toolbar.swift b/Memola/Features/Memo/Toolbar/Toolbar.swift index 3139d53..9b9531c 100644 --- a/Memola/Features/Memo/Toolbar/Toolbar.swift +++ b/Memola/Features/Memo/Toolbar/Toolbar.swift @@ -6,12 +6,11 @@ // import SwiftUI -import PhotosUI import Foundation -import AVFoundation struct Toolbar: View { @Environment(\.dismiss) var dismiss + @Environment(\.horizontalSizeClass) var horizontalSizeClass @ObservedObject var tool: Tool @ObservedObject var canvas: Canvas @@ -19,14 +18,9 @@ struct Toolbar: View { @State var title: String @State var memo: MemoObject - @State var opensCamera: Bool = false - @State var photosPickerItem: PhotosPickerItem? - @State var isCameraAccessDenied: Bool = false @FocusState var textFieldState: Bool - @Namespace var namespace - let size: CGFloat init(size: CGFloat, memo: MemoObject, tool: Tool, canvas: Canvas, history: History) { @@ -47,8 +41,8 @@ struct Toolbar: View { } } .frame(maxWidth: .infinity, alignment: .leading) - if !canvas.locksCanvas { - elementTool + if !canvas.locksCanvas, horizontalSizeClass == .regular { + ElementToolbar(size: size, tool: tool, canvas: canvas) } HStack(spacing: 5) { if !canvas.locksCanvas { @@ -60,40 +54,6 @@ struct Toolbar: View { } .font(.subheadline) .padding(10) - .onChange(of: photosPickerItem) { oldValue, newValue in - if newValue != nil { - Task { - tool.isLoadingPhoto = true - let data = try? await newValue?.loadTransferable(type: Data.self) - if let data, let image = UIImage(data: data) { - tool.selectPhoto(image, for: canvas.canvasID) - } - photosPickerItem = nil - } - } - } - .fullScreenCover(isPresented: $opensCamera) { - let image: Binding = Binding { - tool.selectedPhotoItem?.image - } set: { image in - guard let image else { return } - tool.selectPhoto(image, for: canvas.canvasID) - } - CameraView(image: image, canvas: canvas) - .ignoresSafeArea() - } - .alert("Camera Access Denied", isPresented: $isCameraAccessDenied) { - Button { - if let url = URL(string: UIApplication.openSettingsURLString + "&path=CAMERA/\(String(describing: Bundle.main.bundleIdentifier))") { - UIApplication.shared.open(url) - } - } label: { - Text("Open Settings") - } - Button("Cancel", role: .cancel) { } - } message: { - Text("Memola requires access to the camera to capture photos. Please open Settings and enable camera access.") - } } var closeButton: some View { @@ -116,13 +76,14 @@ struct Toolbar: View { .focused($textFieldState) .textFieldStyle(.plain) .padding(.horizontal, size / 2.5) - .frame(width: 140, height: size) + .frame(width: horizontalSizeClass == .compact ? 100 : 140, height: size) .background(.regularMaterial) .clipShape(.rect(cornerRadius: 8)) .onChange(of: textFieldState) { oldValue, newValue in if !newValue { if !title.isEmpty { memo.title = title + memo.updatedAt = .now } else { title = memo.title } @@ -134,110 +95,6 @@ struct Toolbar: View { .transition(.move(edge: .top).combined(with: .blurReplace)) } - var elementTool: some View { - HStack(spacing: 0) { - Button { - withAnimation { - tool.selectTool(.hand) - } - } label: { - Image(systemName: "hand.draw.fill") - .fontWeight(.heavy) - .contentShape(.circle) - .frame(width: size, height: size) - .foregroundStyle(tool.selection == .hand ? Color.white : Color.accentColor) - .clipShape(.rect(cornerRadius: 8)) - } - .hoverEffect(.lift) - .background { - if tool.selection == .hand { - Color.accentColor - .clipShape(.rect(cornerRadius: 8)) - .matchedGeometryEffect(id: "element.toolbar.bg", in: namespace) - } - } - Button { - withAnimation { - tool.selectTool(.pen) - } - } label: { - Image(systemName: "pencil") - .fontWeight(.heavy) - .contentShape(.circle) - .frame(width: size, height: size) - .foregroundStyle(tool.selection == .pen ? Color.white : Color.accentColor) - .clipShape(.rect(cornerRadius: 8)) - } - .hoverEffect(.lift) - .background { - if tool.selection == .pen { - Color.accentColor - .clipShape(.rect(cornerRadius: 8)) - .matchedGeometryEffect(id: "element.toolbar.bg", in: namespace) - } - } - HStack(spacing: 0) { - Button { - withAnimation { - tool.selectTool(.photo) - } - } label: { - Image(systemName: "photo") - .contentShape(.circle) - .frame(width: size, height: size) - .foregroundStyle(tool.selection == .photo ? Color.white : Color.accentColor) - .clipShape(.rect(cornerRadius: 8)) - } - .hoverEffect(.lift) - .background { - if tool.selection == .photo { - Color.accentColor - .clipShape(.rect(cornerRadius: 8)) - .matchedGeometryEffect(id: "element.toolbar.bg", in: namespace) - } - if tool.selection != .photo { - Color.clear - .matchedGeometryEffect(id: "element.toolbar.photo.options", in: namespace) - } - } - if tool.selection == .photo { - HStack(spacing: 0) { - Button { - openCamera() - } label: { - Image(systemName: "camera.fill") - .contentShape(.circle) - .frame(width: size, height: size) - .clipShape(.rect(cornerRadius: 8)) - } - .hoverEffect(.lift) - PhotosPicker(selection: $photosPickerItem, matching: .images, preferredItemEncoding: .compatible) { - Image(systemName: "photo.fill.on.rectangle.fill") - .contentShape(.circle) - .frame(width: size, height: size) - .clipShape(.rect(cornerRadius: 8)) - } - .hoverEffect(.lift) - } - .matchedGeometryEffect(id: "element.toolbar.photo.options", in: namespace) - .transition(.blurReplace.animation(.easeIn(duration: 0.1))) - } - } - .background { - if tool.selection == .photo { - RoundedRectangle(cornerRadius: 8) - .fill(Color.white.tertiary) - .transition(.move(edge: .leading).animation(.easeIn(duration: 0.1))) - } - } - } - .background { - RoundedRectangle(cornerRadius: 8) - .fill(.regularMaterial) - } - .transition(.move(edge: .top).combined(with: .blurReplace)) - } - var historyControl: some View { HStack { Button { @@ -287,34 +144,18 @@ struct Toolbar: View { } .hoverEffect(.lift) .contentTransition(.symbolEffect(.replace)) - } - - func openCamera() { - let status = AVCaptureDevice.authorizationStatus(for: .video) - switch status { - case .notDetermined: - AVCaptureDevice.requestAccess(for: .video) { status in - withAnimation { - if status { - opensCamera = true - } else { - isCameraAccessDenied = true - } - } - } - case .authorized: - opensCamera = true - default: - isCameraAccessDenied = true - } + .transition(.move(edge: .top).combined(with: .blurReplace)) } func closeMemo() { withAnimation { canvas.state = .closing } + withPersistenceSync(\.viewContext) { context in + try context.saveIfNeeded() + } withPersistence(\.backgroundContext) { context in - try? context.saveIfNeeded() + try context.saveIfNeeded() context.refreshAllObjects() DispatchQueue.main.async { withAnimation { diff --git a/Memola/Features/Memos/MemosView.swift b/Memola/Features/Memos/MemosView.swift deleted file mode 100644 index 680f278..0000000 --- a/Memola/Features/Memos/MemosView.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// MemosView.swift -// Memola -// -// Created by Dscyre Scotti on 5/4/24. -// - -import SwiftUI - -struct MemosView: View { - @Environment(\.managedObjectContext) var managedObjectContext - - @FetchRequest(sortDescriptors: []) var memoObjects: FetchedResults - - @State var memo: MemoObject? - - var body: some View { - NavigationStack { - memoGrid - .navigationTitle("Memos") - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - createMemo(title: "Untitled") - } label: { - Image(systemName: "plus") - } - .hoverEffect() - } - } - } - .fullScreenCover(item: $memo) { memo in - MemoView(memo: memo) - .onDisappear { - withPersistence(\.viewContext) { context in - try context.saveIfNeeded() - context.refreshAllObjects() - } - } - } - } - - var memoGrid: some View { - ScrollView { - LazyVGrid(columns: .init(repeating: GridItem(.flexible()), count: 3)) { - ForEach(memoObjects) { memo in - memoCard(memo) - } - } - .padding() - } - } - - func memoCard(_ memoObject: MemoObject) -> some View { - VStack(alignment: .leading) { - Rectangle() - .frame(height: 150) - Text(memoObject.title) - } - .onTapGesture { - openMemo(for: memoObject) - } - } - - func createMemo(title: String) { - let memoObject = MemoObject(\.viewContext) - memoObject.title = title - memoObject.createdAt = .now - memoObject.updatedAt = .now - - let canvasObject = CanvasObject(context: managedObjectContext) - canvasObject.width = 8_000 - canvasObject.height = 8_000 - canvasObject.gridMode = 1 - - let toolObject = ToolObject(\.viewContext) - toolObject.selection = 0 - toolObject.pens = [] - - let eraserPenObject = PenObject.createObject(\.viewContext, penStyle: .eraser) - eraserPenObject.orderIndex = 0 - let markerPenObjects = [Color.red, Color.blue, Color.yellow, Color.black].enumerated().map { (index, color) in - let penObject = PenObject.createObject(\.viewContext, penStyle: .marker) - penObject.orderIndex = Int16(index) + 1 - penObject.color = color.components - return penObject - } - markerPenObjects.first?.isSelected = true - - let graphicContextObject = GraphicContextObject(\.viewContext) - graphicContextObject.elements = [] - - memoObject.canvas = canvasObject - memoObject.tool = toolObject - - canvasObject.memo = memoObject - canvasObject.graphicContext = graphicContextObject - - toolObject.memo = memoObject - toolObject.pens = .init(array: [eraserPenObject] + markerPenObjects) - - eraserPenObject.tool = toolObject - markerPenObjects.forEach { $0.tool = toolObject } - - graphicContextObject.canvas = canvasObject - - withPersistenceSync(\.viewContext) { context in - try context.save() - DispatchQueue.main.async { - openMemo(for: memoObject) - } - } - } - - func openMemo(for memo: MemoObject) { - self.memo = memo - } -} diff --git a/Memola/Persistence/Objects/MemoObject.swift b/Memola/Persistence/Objects/MemoObject.swift index ad74aa9..c62d8f4 100644 --- a/Memola/Persistence/Objects/MemoObject.swift +++ b/Memola/Persistence/Objects/MemoObject.swift @@ -14,6 +14,9 @@ final class MemoObject: NSManagedObject, Identifiable { @NSManaged var title: String @NSManaged var createdAt: Date @NSManaged var updatedAt: Date + @NSManaged var deletedAt: Date? + @NSManaged var isFavorite: Bool + @NSManaged var isTrash: Bool @NSManaged var tool: ToolObject @NSManaged var canvas: CanvasObject } diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/Contents.json b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/Contents.json new file mode 100644 index 0000000..58541c1 --- /dev/null +++ b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "eraser.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "eraser@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "eraser@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser.png b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser.png new file mode 100644 index 0000000..f78d259 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser@2x.png b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser@2x.png new file mode 100644 index 0000000..95152c5 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser@2x.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser@3x.png b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser@3x.png new file mode 100644 index 0000000..1240b32 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser@3x.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/Contents.json b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/Contents.json new file mode 100644 index 0000000..d7ffdfb --- /dev/null +++ b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "marker-base.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "marker-base@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "marker-base@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base.png b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base.png new file mode 100644 index 0000000..4177713 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base@2x.png b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base@2x.png new file mode 100644 index 0000000..497653b Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base@2x.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base@3x.png b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base@3x.png new file mode 100644 index 0000000..62f9f51 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base@3x.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/Contents.json b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/Contents.json new file mode 100644 index 0000000..69f40d1 --- /dev/null +++ b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "marker-tip.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "marker-tip@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "marker-tip@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip.png b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip.png new file mode 100644 index 0000000..1667820 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip@2x.png b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip@2x.png new file mode 100644 index 0000000..f036441 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip@2x.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip@3x.png b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip@3x.png new file mode 100644 index 0000000..2b0a33a Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip@3x.png differ diff --git a/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents b/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents index 5e6773c..e043833 100644 --- a/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents +++ b/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents @@ -29,6 +29,9 @@ + + +