feat: implement trash view

This commit is contained in:
dscyrescotti
2024-06-30 00:31:56 +07:00
parent c25d93e543
commit 63a619edf9
15 changed files with 520 additions and 109 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ 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
@@ -22,6 +22,7 @@ struct Placeholder: View {
.foregroundStyle(.primary)
Text(info.description)
.font(.callout)
.lineLimit(.none)
.fontWeight(.regular)
.frame(minHeight: 50, alignment: .top)
}
@@ -41,15 +42,29 @@ extension Placeholder {
static let memoNotFound: Info = {
let icon: String = "sparkle.magnifyingglass"
let title: String = "No Memos Found"
let description: String = "Explore your other memos or create your own."
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"
let description: String = "Create a new memo to jot your thoughts or notes down."
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)
}()
}
@@ -0,0 +1,29 @@
//
// DashboardView.swift
// Memola
//
// Created by Dscyre Scotti on 6/29/24.
//
import SwiftUI
struct DashboardView: View {
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@State var sidebarItem: SidebarItem? = .memos
var body: some View {
NavigationSplitView {
Sidebar(sidebarItem: $sidebarItem, horizontalSizeClass: horizontalSizeClass)
} detail: {
switch sidebarItem {
case .memos:
MemosView()
case .trash:
TrashView()
default:
MemosView()
}
}
}
}
@@ -16,13 +16,15 @@ struct MemosView: View {
@State var query: String = ""
@State var currentDate: Date = .now
@AppStorage("memola.memo-objects.sort") var sort: Sort = .recent
@AppStorage("memola.memo-objects.filter") var filter: Filter = .none
@AppStorage("memola.memo-objects.memos.sort") var sort: Sort = .recent
@AppStorage("memola.memo-objects.memos.filter") var filter: Filter = .none
let cellWidth: CGFloat = 250
let cellHeight: CGFloat = 150
let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
var placeholder: Placeholder.Info {
query.isEmpty ? .memoEmpty : .memoNotFound
}
init() {
let standard = UserDefaults.standard
var descriptors: [SortDescriptor<MemoObject>] = []
@@ -38,57 +40,43 @@ struct MemosView: View {
}
var body: some View {
NavigationStack {
memoGrid
.searchable(text: $query, placement: .toolbar, prompt: Text("Search"))
.toolbar {
MemoGrid(memoObjects: memoObjects, placeholder: placeholder) { memoObject in
memoCard(memoObject)
}
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $query, placement: .toolbar, prompt: Text("Search"))
.toolbar {
if horizontalSizeClass == .compact {
ToolbarItem(placement: .principal) {
Text("Memos")
.font(.title3)
.fontWeight(.bold)
}
} else {
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 {
}
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)
}
}
} label: {
Image(systemName: "arrow.up.arrow.down.circle")
}
.hoverEffect(.lift)
Menu {
.pickerStyle(.automatic)
Picker("", selection: $filter) {
ForEach(Filter.all) { filter in
Text(filter.name)
@@ -96,66 +84,67 @@ struct MemosView: View {
}
}
.pickerStyle(.automatic)
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
} 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")
}
}
}
}
}
.fullScreenCover(item: $memo) { memo in
MemoView(memo: memo)
.onDisappear {
withPersistence(\.viewContext) { context in
try context.saveIfNeeded()
context.refreshAllObjects()
}
}
}
.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()
}
}
@ViewBuilder
var memoGrid: some View {
if memoObjects.isEmpty {
Placeholder(info: query.isEmpty ? .memoEmpty : .memoNotFound)
} 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) { memo in
memoCard(memo)
}
.fullScreenCover(item: $memo) { memo in
MemoView(memo: memo)
.onDisappear {
withPersistence(\.viewContext) { context in
try context.saveIfNeeded()
context.refreshAllObjects()
}
}
.padding()
}
}
}
.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 {
VStack(alignment: .leading, spacing: 5) {
Rectangle()
.frame(height: cellHeight)
.clipShape(RoundedRectangle(cornerRadius: 10))
MemoCard(memoObject: memoObject) { card in
card
.contextMenu {
Button {
openMemo(for: memoObject)
@@ -183,15 +172,10 @@ struct MemosView: View {
}
.padding(5)
}
VStack(alignment: .leading, spacing: 2) {
Text(memoObject.title)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
Text("Edited \(memoObject.updatedAt.getTimeDifference(to: currentDate))")
.font(.caption)
.foregroundStyle(.secondary)
}
} details: {
Text("Edited \(memoObject.updatedAt.getTimeDifference(to: currentDate))")
.font(.caption)
.foregroundStyle(.secondary)
}
.onTapGesture {
openMemo(for: memoObject)
@@ -272,6 +256,7 @@ struct MemosView: View {
func markAsTrash(for memo: MemoObject) {
memo.isTrash = true
memo.deletedAt = .now
withPersistence(\.viewContext) { context in
try context.saveIfNeeded()
}
@@ -46,4 +46,19 @@ extension Sort {
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)]
}
}
}
@@ -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()
}
}
}
}
@@ -0,0 +1,36 @@
//
// MemoGrid.swift
// Memola
//
// Created by Dscyre Scotti on 6/29/24.
//
import SwiftUI
struct MemoGrid<Card: View>: View {
let cellWidth: CGFloat = 250
let cellHeight: CGFloat = 150
let memoObjects: FetchedResults<MemoObject>
let placeholder: Placeholder.Info
@ViewBuilder let card: (MemoObject) -> Card
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()
}
}
}
}
}
@@ -0,0 +1,18 @@
//
// MemoPreview.swift
// Memola
//
// Created by Dscyre Scotti on 6/29/24.
//
import SwiftUI
struct MemoPreview: View {
let cellHeight: CGFloat = 150
var body: some View {
Rectangle()
.frame(height: cellHeight)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
@@ -0,0 +1,85 @@
//
// 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 = ""
var placeholder: Placeholder.Info {
query.isEmpty ? .trashEmpty : .trashNotFound
}
init() {
let descriptors = [SortDescriptor(\MemoObject.deletedAt, order: .reverse)]
let predicate = NSPredicate(format: "isTrash = YES")
_memoObjects = FetchRequest(sortDescriptors: descriptors, predicate: predicate)
}
var body: some View {
MemoGrid(memoObjects: memoObjects, placeholder: placeholder) { memoObject in
memoCard(memoObject)
}
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $query, placement: .toolbar, prompt: Text("Search"))
.toolbar {
if horizontalSizeClass == .compact {
ToolbarItem(placement: .principal) {
Text("Trash")
.font(.title3)
.fontWeight(.bold)
}
} else {
ToolbarItem(placement: .topBarLeading) {
Text("Memola")
.font(.title3)
.fontWeight(.bold)
}
}
}
.onChange(of: query) { oldValue, newValue in
updatePredicate()
}
}
func memoCard(_ memoObject: MemoObject) -> some View {
MemoCard(memoObject: memoObject) { card in
card
.contextMenu {
Button {
} label: {
Label("Restore", systemImage: "square.and.arrow.down")
}
Button(role: .destructive) {
} label: {
Label("Delete Permanently", systemImage: "trash")
}
}
} details: {
Text("Deleted on \(memoObject.deletedAt.formatted(date: .abbreviated, time: .standard))")
.font(.caption)
.foregroundStyle(.secondary)
}
.onTapGesture {
}
}
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)
}
}
@@ -0,0 +1,78 @@
//
// 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
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)
}
}
@@ -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]
}
@@ -14,6 +14,7 @@ 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
@@ -29,6 +29,7 @@
</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"/>