Merge pull request #56 from dscyrescotti/feature/memo-grid
Fine tune memo grid view and memo view for iOS
@@ -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 = "<group>"; };
|
||||
EC01511F2C305D7B008A115E /* SidebarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarItem.swift; sourceTree = "<group>"; };
|
||||
EC0151222C306089008A115E /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = "<group>"; };
|
||||
EC0151252C3067B9008A115E /* TrashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashView.swift; sourceTree = "<group>"; };
|
||||
EC0151292C306935008A115E /* MemoGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoGrid.swift; sourceTree = "<group>"; };
|
||||
EC01512B2C306BEF008A115E /* MemoCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoCard.swift; sourceTree = "<group>"; };
|
||||
EC01512D2C30727F008A115E /* MemoPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoPreview.swift; sourceTree = "<group>"; };
|
||||
EC0D14202BF79C73009BFE5F /* ToolObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolObject.swift; sourceTree = "<group>"; };
|
||||
EC0D14232BF79C98009BFE5F /* MemolaModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MemolaModel.xcdatamodel; sourceTree = "<group>"; };
|
||||
EC0D14252BF7A8C9009BFE5F /* PenObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PenObject.swift; sourceTree = "<group>"; };
|
||||
EC0D14272BF7BF20009BFE5F /* ContextMenuViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuViewModifier.swift; sourceTree = "<group>"; };
|
||||
EC1815072C2D980B00541369 /* Sort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sort.swift; sourceTree = "<group>"; };
|
||||
EC1815092C2DA09E00541369 /* Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filter.swift; sourceTree = "<group>"; };
|
||||
EC18150C2C2DAC3700541369 /* Placeholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Placeholder.swift; sourceTree = "<group>"; };
|
||||
EC18150E2C2DB13200541369 /* Date++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date++.swift"; sourceTree = "<group>"; };
|
||||
EC1B783C2BFA0AC9005A34E2 /* Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toolbar.swift; sourceTree = "<group>"; };
|
||||
EC2106AC2C10C2A700FBE27C /* AnyStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyStroke.swift; sourceTree = "<group>"; };
|
||||
EC2BEBF32C0F5FF7005DB0AF /* RTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTree.swift; sourceTree = "<group>"; };
|
||||
@@ -208,6 +231,7 @@
|
||||
ECD12A902C1B04EA00B96E12 /* PhotoRenderPass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoRenderPass.swift; sourceTree = "<group>"; };
|
||||
ECD12A922C1B062000B96E12 /* Photo.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Photo.metal; sourceTree = "<group>"; };
|
||||
ECD12A942C1B1FA200B96E12 /* PhotoVertex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoVertex.swift; sourceTree = "<group>"; };
|
||||
ECDAC07A2C318DBC0000ED77 /* ElementToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementToolbar.swift; sourceTree = "<group>"; };
|
||||
ECE883BC2C00AA170045C53D /* EraserStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EraserStroke.swift; sourceTree = "<group>"; };
|
||||
ECE883BE2C00AB440045C53D /* Stroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stroke.swift; sourceTree = "<group>"; };
|
||||
ECE883C02C00C9CB0045C53D /* StrokeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrokeStyle.swift; sourceTree = "<group>"; };
|
||||
@@ -231,6 +255,61 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
EC01511A2C305ABB008A115E /* Dashboard */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EC0151272C306906008A115E /* Details */,
|
||||
EC0151212C30605F008A115E /* Sidebar */,
|
||||
EC01511C2C305C99008A115E /* Dashboard */,
|
||||
);
|
||||
path = Dashboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EC01511C2C305C99008A115E /* Dashboard */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EC01511D2C305CA9008A115E /* DashboardView.swift */,
|
||||
);
|
||||
path = Dashboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EC0151212C30605F008A115E /* Sidebar */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EC0151222C306089008A115E /* Sidebar.swift */,
|
||||
EC01511F2C305D7B008A115E /* SidebarItem.swift */,
|
||||
);
|
||||
path = Sidebar;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EC0151242C3067B2008A115E /* Trash */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EC0151252C3067B9008A115E /* TrashView.swift */,
|
||||
);
|
||||
path = Trash;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EC0151272C306906008A115E /* Details */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EC0151282C306927008A115E /* Shared */,
|
||||
EC0151242C3067B2008A115E /* Trash */,
|
||||
ECA738782BE5EEF700A4542E /* Memos */,
|
||||
);
|
||||
path = Details;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EC0151282C306927008A115E /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EC0151292C306935008A115E /* MemoGrid.swift */,
|
||||
EC01512B2C306BEF008A115E /* MemoCard.swift */,
|
||||
EC01512D2C30727F008A115E /* MemoPreview.swift */,
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EC1437B42BE748E60022C903 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -247,9 +326,18 @@
|
||||
path = ViewController;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EC18150B2C2DA3AD00541369 /* Placeholder */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EC18150C2C2DAC3700541369 /* Placeholder.swift */,
|
||||
);
|
||||
path = Placeholder;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
@@ -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 = "<group>";
|
||||
@@ -745,6 +837,14 @@
|
||||
path = Photo;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
ECDAC0792C318DAF0000ED77 /* ElementToolbar */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ECDAC07A2C318DBC0000ED77 /* ElementToolbar.swift */,
|
||||
);
|
||||
path = ElementToolbar;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
73
Memola/Components/Views/Placeholder/Placeholder.swift
Normal file
@@ -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)
|
||||
}()
|
||||
}
|
||||
}
|
||||
32
Memola/Extensions/Date++.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
39
Memola/Features/Dashboard/Dashboard/DashboardView.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Memola/Features/Dashboard/Details/Memos/Filter.swift
Normal file
@@ -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]
|
||||
}
|
||||
252
Memola/Features/Dashboard/Details/Memos/MemosView.swift
Normal file
@@ -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<MemoObject>
|
||||
|
||||
@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<MemoObject?>) {
|
||||
_memo = memo
|
||||
let standard = UserDefaults.standard
|
||||
var descriptors: [SortDescriptor<MemoObject>] = []
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
64
Memola/Features/Dashboard/Details/Memos/Sort.swift
Normal file
@@ -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<MemoObject>] {
|
||||
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<MemoObject>] {
|
||||
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)]
|
||||
}
|
||||
}
|
||||
}
|
||||
37
Memola/Features/Dashboard/Details/Shared/MemoCard.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// MemoCard.swift
|
||||
// Memola
|
||||
//
|
||||
// Created by Dscyre Scotti on 6/29/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MemoCard<Preview: View, Detail: View>: 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
Memola/Features/Dashboard/Details/Shared/MemoGrid.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// MemoGrid.swift
|
||||
// Memola
|
||||
//
|
||||
// Created by Dscyre Scotti on 6/29/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MemoGrid<Card: View>: View {
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||||
let memoObjects: FetchedResults<MemoObject>
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
Memola/Features/Dashboard/Details/Shared/MemoPreview.swift
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
147
Memola/Features/Dashboard/Details/Trash/TrashView.swift
Normal file
@@ -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<MemoObject>
|
||||
|
||||
@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<MemoObject?>, sidebarItem: Binding<SidebarItem?>) {
|
||||
_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<Bool> {
|
||||
restoredMemo != nil
|
||||
} set: { _ in
|
||||
restoredMemo = nil
|
||||
}
|
||||
let deletesMemo = Binding<Bool> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
87
Memola/Features/Dashboard/Sidebar/Sidebar.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
35
Memola/Features/Dashboard/Sidebar/SidebarItem.swift
Normal file
@@ -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]
|
||||
}
|
||||
244
Memola/Features/Memo/ElementToolbar/ElementToolbar.swift
Normal file
@@ -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<UIImage?> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<UIImage?> = 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 {
|
||||
|
||||
@@ -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<MemoObject>
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
23
Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/Contents.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
BIN
Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser.png
vendored
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 29 KiB |
23
Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/Contents.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
BIN
Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base.png
vendored
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 26 KiB |
23
Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/Contents.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
BIN
Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip.png
vendored
Normal file
|
After Width: | Height: | Size: 440 B |
BIN
Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 904 B |
BIN
Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
@@ -29,6 +29,9 @@
|
||||
</entity>
|
||||
<entity name="MemoObject" representedClassName="MemoObject" syncable="YES">
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="isFavorite" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="isTrash" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="title" attributeType="String"/>
|
||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="canvas" maxCount="1" deletionRule="Cascade" destinationEntity="CanvasObject" inverseName="memo" inverseEntity="CanvasObject"/>
|
||||
|
||||