From df6481f069b774e946f98320976539cfae77a8fd Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 30 May 2025 21:22:06 -0700 Subject: [PATCH 01/21] Modernize demos. --- Examples/Reminders/ReminderForm.swift | 240 ++++++++++----------- Examples/Reminders/RemindersDetail.swift | 87 ++++---- Examples/Reminders/RemindersListForm.swift | 64 +++--- Examples/Reminders/RemindersListRow.swift | 2 +- Examples/Reminders/RemindersLists.swift | 186 ++++++++-------- Examples/Reminders/Schema.swift | 159 +++++++------- Examples/Reminders/SearchReminders.swift | 4 +- Examples/Reminders/TagRow.swift | 2 +- Examples/Reminders/TagsForm.swift | 34 +-- 9 files changed, 389 insertions(+), 389 deletions(-) diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index 6f94e7ad..d864360b 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -38,126 +38,126 @@ struct ReminderFormView: View { .lineLimit(4) .padding([.leading, .trailing], -5) - Section { - Button { - isPresentingTagsPopover = true - } label: { - HStack { - Image(systemName: "number.square.fill") - .font(.title) - .foregroundStyle(.gray) - Text("Tags") - .foregroundStyle(Color(.label)) - Spacer() - if let tagsDetail { - tagsDetail - .lineLimit(1) - .truncationMode(.tail) - .font(.callout) - .foregroundStyle(.gray) - } - Image(systemName: "chevron.right") - } - } - } - .popover(isPresented: $isPresentingTagsPopover) { - NavigationStack { - TagsView(selectedTags: $selectedTags) - } - } - - Section { - Toggle(isOn: $reminder.isDateSet.animation()) { - HStack { - Image(systemName: "calendar.circle.fill") - .font(.title) - .foregroundStyle(.red) - Text("Date") - } - } - if let dueDate = reminder.dueDate { - DatePicker( - "", - selection: $reminder.dueDate[coalesce: dueDate], - displayedComponents: [.date, .hourAndMinute] - ) - .padding([.top, .bottom], 2) - } - } - - Section { - Toggle(isOn: $reminder.isFlagged) { - HStack { - Image(systemName: "flag.circle.fill") - .font(.title) - .foregroundStyle(.red) - Text("Flag") - } - } - Picker(selection: $reminder.priority) { - Text("None").tag(Priority?.none) - Divider() - Text("High").tag(Priority.high) - Text("Medium").tag(Priority.medium) - Text("Low").tag(Priority.low) - } label: { - HStack { - Image(systemName: "exclamationmark.circle.fill") - .font(.title) - .foregroundStyle(.red) - Text("Priority") - } - } - - Picker(selection: $remindersList) { - ForEach(remindersLists) { remindersList in - Text(remindersList.title) - .tag(remindersList) - .buttonStyle(.plain) - } - } label: { - HStack { - Image(systemName: "list.bullet.circle.fill") - .font(.title) - .foregroundStyle(remindersList.color) - Text("List") - } - } - .onChange(of: remindersList) { - reminder.remindersListID = remindersList.id - } - } - } - .padding(.top, -28) - .task { - guard let reminderID = reminder.id - else { return } - do { - selectedTags = try await database.read { db in - try Tag - .order(by: \.title) - .join(ReminderTag.all) { $0.id.eq($1.tagID) } - .where { $1.reminderID.eq(reminderID) } - .select { tag, _ in tag } - .fetchAll(db) - } - } catch { - selectedTags = [] - reportIssue(error) - } - } - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem { - Button(action: saveButtonTapped) { - Text("Save") - } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - } +// Section { +// Button { +// isPresentingTagsPopover = true +// } label: { +// HStack { +// Image(systemName: "number.square.fill") +// .font(.title) +// .foregroundStyle(.gray) +// Text("Tags") +// .foregroundStyle(Color(.label)) +// Spacer() +// if let tagsDetail { +// tagsDetail +// .lineLimit(1) +// .truncationMode(.tail) +// .font(.callout) +// .foregroundStyle(.gray) +// } +// Image(systemName: "chevron.right") +// } +// } +// } +// .popover(isPresented: $isPresentingTagsPopover) { +// NavigationStack { +// TagsView(selectedTags: $selectedTags) +// } +// } + +// Section { +// Toggle(isOn: $reminder.isDateSet.animation()) { +// HStack { +// Image(systemName: "calendar.circle.fill") +// .font(.title) +// .foregroundStyle(.red) +// Text("Date") +// } +// } +// if let dueDate = reminder.dueDate { +// DatePicker( +// "", +// selection: $reminder.dueDate[coalesce: dueDate], +// displayedComponents: [.date, .hourAndMinute] +// ) +// .padding([.top, .bottom], 2) +// } +// } + +// Section { +// Toggle(isOn: $reminder.isFlagged) { +// HStack { +// Image(systemName: "flag.circle.fill") +// .font(.title) +// .foregroundStyle(.red) +// Text("Flag") +// } +// } +// Picker(selection: $reminder.priority) { +// Text("None").tag(Priority?.none) +// Divider() +// Text("High").tag(Priority.high) +// Text("Medium").tag(Priority.medium) +// Text("Low").tag(Priority.low) +// } label: { +// HStack { +// Image(systemName: "exclamationmark.circle.fill") +// .font(.title) +// .foregroundStyle(.red) +// Text("Priority") +// } +// } +// +// Picker(selection: $remindersList) { +// ForEach(remindersLists) { remindersList in +// Text(remindersList.title) +// .tag(remindersList) +// .buttonStyle(.plain) +// } +// } label: { +// HStack { +// Image(systemName: "list.bullet.circle.fill") +// .font(.title) +// .foregroundStyle(remindersList.color) +// Text("List") +// } +// } +// .onChange(of: remindersList) { +// reminder.remindersListID = remindersList.id +// } +// } +// } +// .padding(.top, -28) +// .task { +// guard let reminderID = reminder.id +// else { return } +// do { +// selectedTags = try await database.read { db in +// try Tag +// .order(by: \.title) +// .join(ReminderTag.all) { $0.id.eq($1.tagID) } +// .where { $1.reminderID.eq(reminderID) } +// .select { tag, _ in tag } +// .fetchAll(db) +// } +// } catch { +// selectedTags = [] +// reportIssue(error) +// } +// } +// .navigationBarTitleDisplayMode(.inline) +// .toolbar { +// ToolbarItem { +// Button(action: saveButtonTapped) { +// Text("Save") +// } +// } +// ToolbarItem(placement: .cancellationAction) { +// Button("Cancel") { +// dismiss() +// } +// } } } diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 94b702de..5dc72c39 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -184,45 +184,54 @@ struct RemindersDetailView: View { } fileprivate var remindersQuery: some StructuredQueriesCore.Statement { - let query = - Reminder - .where { - if !showCompleted { - !$0.isCompleted - } - } - .order { $0.isCompleted } - .order { - switch ordering { - case .dueDate: $0.dueDate - case .manual: $0.position - case .priority: ($0.priority.desc(), $0.isFlagged.desc()) - case .title: $0.title - } - } - .withTags - .where { reminder, _, tag in - switch detailType { - case .all: !reminder.isCompleted - case .completed: reminder.isCompleted - case .flagged: reminder.isFlagged - case .list(let list): reminder.remindersListID.eq(list.id) - case .scheduled: reminder.isScheduled - case .tags(let tags): tag.id.ifnull(0).in(tags.map(\.id)) - case .today: reminder.isToday - } - } - .join(RemindersList.all) { $0.remindersListID.eq($3.id) } - .select { - ReminderState.Columns( - reminder: $0, - remindersList: $3, - isPastDue: $0.isPastDue, - notes: $0.inlineNotes.substr(0, 200), - tags: #sql("\($2.jsonNames)") - ) - } - return query + Reminder.select { + ReminderState.Columns.init( + reminder: $0, + remindersList: #sql(""), + isPastDue: #sql(""), + notes: #sql(""), + tags: #sql("") + ) + } +// let query = +// Reminder +// .where { +// if !showCompleted { +// !$0.isCompleted +// } +// } +// .order { $0.isCompleted } +// .order { +// switch ordering { +// case .dueDate: $0.dueDate.asc(nulls: .last) +// case .manual: $0.position +// case .priority: ($0.priority.desc(), $0.isFlagged.desc()) +// case .title: $0.title +// } +// } +// .withTags +// .where { reminder, _, tag in +// switch detailType { +// case .all: !reminder.isCompleted +// case .completed: reminder.isCompleted +// case .flagged: reminder.isFlagged +// case .list(let list): reminder.remindersListID.eq(list.id) +// case .scheduled: reminder.isScheduled +// case .tags(let tags): tag.id.ifnull(0).in(tags.map(\.id)) +// case .today: reminder.isToday +// } +// } +// .join(RemindersList.all) { $0.remindersListID.eq($3.id) } +// .select { +// ReminderState.Columns( +// reminder: $0, +// remindersList: $3, +// isPastDue: $0.isPastDue, +// notes: $0.inlineNotes.substr(0, 200), +// tags: #sql("\($2.jsonNames)") +// ) +// } +// return query } @Selection diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 37bb2acc..1f75672b 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -14,39 +14,39 @@ struct RemindersListForm: View { var body: some View { Form { - Section { - VStack { - TextField("List Name", text: $remindersList.title) - .font(.system(.title2, design: .rounded, weight: .bold)) - .foregroundStyle(remindersList.color) - .multilineTextAlignment(.center) - .padding() - .textFieldStyle(.plain) - } - .background(Color(.secondarySystemBackground)) - .clipShape(.buttonBorder) - } - ColorPicker("Color", selection: $remindersList.color) - } - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem { - Button("Save") { - withErrorReporting { - try database.write { db in - try RemindersList.upsert(remindersList) - .execute(db) - } - } - dismiss() - } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - } +// Section { +// VStack { +// TextField("List Name", text: $remindersList.title) +// .font(.system(.title2, design: .rounded, weight: .bold)) +// .foregroundStyle(remindersList.color) +// .multilineTextAlignment(.center) +// .padding() +// .textFieldStyle(.plain) +// } +// .background(Color(.secondarySystemBackground)) +// .clipShape(.buttonBorder) +// } +// ColorPicker("Color", selection: $remindersList.color) } +// .navigationBarTitleDisplayMode(.inline) +// .toolbar { +// ToolbarItem { +// Button("Save") { +// withErrorReporting { +// try database.write { db in +// try RemindersList.upsert(remindersList) +// .execute(db) +// } +// } +// dismiss() +// } +// } +// ToolbarItem(placement: .cancellationAction) { +// Button("Cancel") { +// dismiss() +// } +// } +// } } } diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index 553879a9..493b22f0 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -56,7 +56,7 @@ struct RemindersListRow: View { RemindersListRow( remindersCount: 10, remindersList: RemindersList( - id: 1, + id: UUID(), title: "Personal" ) ) diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index e9e6a241..14fffae4 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -67,101 +67,101 @@ struct RemindersListsView: View { var body: some View { List { if searchText.isEmpty { - Section { - Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 16) { - GridRow { - ReminderGridCell( - color: .blue, - count: stats.todayCount, - iconName: "calendar.circle.fill", - title: "Today" - ) { - remindersDetailType = .today - } - ReminderGridCell( - color: .red, - count: stats.scheduledCount, - iconName: "calendar.circle.fill", - title: "Scheduled" - ) { - remindersDetailType = .scheduled - } - } - GridRow { - ReminderGridCell( - color: .gray, - count: stats.allCount, - iconName: "tray.circle.fill", - title: "All" - ) { - remindersDetailType = .all - } - ReminderGridCell( - color: .orange, - count: stats.flaggedCount, - iconName: "flag.circle.fill", - title: "Flagged" - ) { - remindersDetailType = .flagged - } - } - GridRow { - ReminderGridCell( - color: .gray, - count: nil, - iconName: "checkmark.circle.fill", - title: "Completed" - ) { - remindersDetailType = .completed - } - } - } - .buttonStyle(.plain) - .listRowBackground(Color.clear) - .padding([.leading, .trailing], -20) - } +// Section { +// Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 16) { +// GridRow { +// ReminderGridCell( +// color: .blue, +// count: stats.todayCount, +// iconName: "calendar.circle.fill", +// title: "Today" +// ) { +// remindersDetailType = .today +// } +// ReminderGridCell( +// color: .red, +// count: stats.scheduledCount, +// iconName: "calendar.circle.fill", +// title: "Scheduled" +// ) { +// remindersDetailType = .scheduled +// } +// } +// GridRow { +// ReminderGridCell( +// color: .gray, +// count: stats.allCount, +// iconName: "tray.circle.fill", +// title: "All" +// ) { +// remindersDetailType = .all +// } +// ReminderGridCell( +// color: .orange, +// count: stats.flaggedCount, +// iconName: "flag.circle.fill", +// title: "Flagged" +// ) { +// remindersDetailType = .flagged +// } +// } +// GridRow { +// ReminderGridCell( +// color: .gray, +// count: nil, +// iconName: "checkmark.circle.fill", +// title: "Completed" +// ) { +// remindersDetailType = .completed +// } +// } +// } +// .buttonStyle(.plain) +// .listRowBackground(Color.clear) +// .padding([.leading, .trailing], -20) +// } - Section { - ForEach(remindersLists) { state in - NavigationLink { - RemindersDetailView(detailType: .list(state.remindersList)) - } label: { - RemindersListRow( - remindersCount: state.remindersCount, - remindersList: state.remindersList - ) - } - } - .onMove { indexSet, index in - move(from: indexSet, to: index) - } - } header: { - Text("My Lists") - .font(.system(.title2, design: .rounded, weight: .bold)) - .foregroundStyle(Color(.label)) - .textCase(nil) - .padding(.top, -16) - .padding([.leading, .trailing], 4) - } - .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) +// Section { +// ForEach(remindersLists) { state in +// NavigationLink { +// RemindersDetailView(detailType: .list(state.remindersList)) +// } label: { +// RemindersListRow( +// remindersCount: state.remindersCount, +// remindersList: state.remindersList +// ) +// } +// } +// .onMove { indexSet, index in +// move(from: indexSet, to: index) +// } +// } header: { +// Text("My Lists") +// .font(.system(.title2, design: .rounded, weight: .bold)) +// .foregroundStyle(Color(.label)) +// .textCase(nil) +// .padding(.top, -16) +// .padding([.leading, .trailing], 4) +// } +// .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) - Section { - ForEach(tags) { tag in - NavigationLink { - RemindersDetailView(detailType: .tags([tag])) - } label: { - TagRow(tag: tag) - } - } - } header: { - Text("Tags") - .font(.system(.title2, design: .rounded, weight: .bold)) - .foregroundStyle(Color(.label)) - .textCase(nil) - .padding(.top, -16) - .padding([.leading, .trailing], 4) - } - .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) +// Section { +// ForEach(tags) { tag in +// NavigationLink { +// RemindersDetailView(detailType: .tags([tag])) +// } label: { +// TagRow(tag: tag) +// } +// } +// } header: { +// Text("Tags") +// .font(.system(.title2, design: .rounded, weight: .bold)) +// .foregroundStyle(Color(.label)) +// .textCase(nil) +// .padding(.top, -16) +// .padding([.leading, .trailing], 4) +// } +// .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) } else { SearchRemindersView(searchText: searchText) } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index dba8b3bd..3ab4a6df 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -6,23 +6,30 @@ import SwiftUI @Table struct RemindersList: Hashable, Identifiable { - var id: Int + let id: UUID @Column(as: Color.HexRepresentation.self) var color = Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255) var position = 0 var title = "" } +@Table +struct ReminderSection: Hashable, Identifiable { + let id: UUID + var title = "" +} + @Table struct Reminder: Equatable, Identifiable { - var id: Int + let id: UUID var dueDate: Date? var isCompleted = false var isFlagged = false var notes = "" var priority: Priority? - var remindersListID: Int + var remindersListID: RemindersList.ID var position = 0 + var sectionID: Section.ID? var title = "" } @@ -62,7 +69,7 @@ enum Priority: Int, QueryBindable { @Table struct Tag: Hashable, Identifiable { - var id: Int + let id: UUID var title = "" } @@ -116,8 +123,18 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "remindersLists" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), "color" INTEGER NOT NULL DEFAULT \(raw: 0x4a99_ef00), + "position" INTEGER NOT NULL DEFAULT 0, + "title" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + try #sql( + """ + CREATE TABLE "sections" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), "title" TEXT NOT NULL ) STRICT """ @@ -126,16 +143,19 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "reminders" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), "dueDate" TEXT, "isCompleted" INTEGER NOT NULL DEFAULT 0, "isFlagged" INTEGER NOT NULL DEFAULT 0, "notes" TEXT, + "position" INTEGER NOT NULL DEFAULT 0, "priority" INTEGER, "remindersListID" INTEGER NOT NULL, + "sectionID" TEXT, "title" TEXT NOT NULL, - FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE, + FOREIGN KEY("sectionID") REFERENCES "sections"("id") ON DELETE CASCADE ) STRICT """ ) @@ -143,7 +163,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "tags" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), "title" TEXT NOT NULL COLLATE NOCASE UNIQUE ) STRICT """ @@ -152,8 +172,8 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "remindersTags" ( - "reminderID" INTEGER NOT NULL, - "tagID" INTEGER NOT NULL, + "reminderID" TEXT NOT NULL, + "tagID" TEXT NOT NULL, FOREIGN KEY("reminderID") REFERENCES "reminders"("id") ON DELETE CASCADE, FOREIGN KEY("tagID") REFERENCES "tags"("id") ON DELETE CASCADE @@ -162,17 +182,10 @@ func appDatabase() throws -> any DatabaseWriter { ) .execute(db) } - migrator.registerMigration("Add 'position' column to 'remindersLists'") { db in - try #sql( - """ - ALTER TABLE "remindersLists" - ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0 - """ - ) - .execute(db) + try database.write { db in try #sql( """ - CREATE TRIGGER "default_position_reminders_lists" + CREATE TEMPORARY TRIGGER "default_position_reminders_lists" AFTER INSERT ON "remindersLists" FOR EACH ROW BEGIN UPDATE "remindersLists" @@ -182,35 +195,9 @@ func appDatabase() throws -> any DatabaseWriter { """ ) .execute(db) - } - migrator.registerMigration("Add 'position' column to 'reminders'") { db in - try #sql( - """ - ALTER TABLE "reminders" - ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0 - """ - ) - .execute(db) - // Backfill position of reminders based on their completion status and due date. try #sql( """ - WITH "reminderPositions" AS ( - SELECT - "reminders"."id", - ROW_NUMBER() OVER (PARTITION BY "remindersListID" ORDER BY id) - 1 AS "position" - FROM "reminders" - ORDER BY NOT "isCompleted", "dueDate" DESC - ) - UPDATE "reminders" - SET "position" = "reminderPositions"."position" - FROM "reminderPositions" - WHERE "reminders"."id" = "reminderPositions"."id" - """ - ) - .execute(db) - try #sql( - """ - CREATE TRIGGER "default_position_reminders" + CREATE TEMPORARY TRIGGER "default_position_reminders" AFTER INSERT ON "reminders" FOR EACH ROW BEGIN UPDATE "reminders" @@ -221,6 +208,7 @@ func appDatabase() throws -> any DatabaseWriter { ) .execute(db) } + #if DEBUG && targetEnvironment(simulator) if context != .test { migrator.registerMigration("Seed sample data") { db in @@ -238,114 +226,117 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") #if DEBUG extension Database { func seedSampleData() throws { + let remindersListIDs = (1...3).map { _ in UUID() } + let reminderIDs = (1...10).map { _ in UUID() } + let tagIDs = (1...7).map { _ in UUID() } try seed { RemindersList( - id: 1, + id: remindersListIDs[0], color: Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255), title: "Personal" ) RemindersList( - id: 2, + id: remindersListIDs[1], color: Color(red: 0xed / 255, green: 0x89 / 255, blue: 0x35 / 255), title: "Family" ) RemindersList( - id: 3, + id: remindersListIDs[2], color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), title: "Business" ) Reminder( - id: 1, + id: reminderIDs[0], notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", - remindersListID: 1, + remindersListID: remindersListIDs[0], title: "Groceries" ) Reminder( - id: 2, + id: reminderIDs[1], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isFlagged: true, - remindersListID: 1, + remindersListID: remindersListIDs[0], title: "Haircut" ) Reminder( - id: 3, + id: reminderIDs[2], dueDate: Date(), notes: "Ask about diet", priority: .high, - remindersListID: 1, + remindersListID: remindersListIDs[0], title: "Doctor appointment" ) Reminder( - id: 4, + id: reminderIDs[3], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 190), isCompleted: true, - remindersListID: 1, + remindersListID: remindersListIDs[0], title: "Take a walk" ) Reminder( - id: 5, + id: reminderIDs[4], dueDate: Date(), - remindersListID: 1, + remindersListID: remindersListIDs[0], title: "Buy concert tickets" ) Reminder( - id: 6, + id: reminderIDs[5], dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), isFlagged: true, priority: .high, - remindersListID: 2, + remindersListID: remindersListIDs[1], title: "Pick up kids from school" ) Reminder( - id: 7, + id: reminderIDs[6], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isCompleted: true, priority: .low, - remindersListID: 2, + remindersListID: remindersListIDs[1], title: "Get laundry" ) Reminder( - id: 8, + id: reminderIDs[7], dueDate: Date().addingTimeInterval(60 * 60 * 24 * 4), isCompleted: false, priority: .high, - remindersListID: 2, + remindersListID: remindersListIDs[1], title: "Take out trash" ) Reminder( - id: 9, + id: reminderIDs[8], dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), notes: """ Status of tax return Expenses for next year Changing payroll company """, - remindersListID: 3, + remindersListID: remindersListIDs[2], title: "Call accountant" ) Reminder( - id: 10, + id: reminderIDs[9], dueDate: Date().addingTimeInterval(-60 * 60 * 24 * 2), isCompleted: true, priority: .medium, - remindersListID: 3, + remindersListID: remindersListIDs[2], title: "Send weekly emails" ) - Tag(id: 1, title: "car") - Tag(id: 2, title: "kids") - Tag(id: 3, title: "someday") - Tag(id: 4, title: "optional") - Tag(id: 5, title: "social") - Tag(id: 6, title: "night") - Tag(id: 7, title: "adulting") - ReminderTag(reminderID: 1, tagID: 3) - ReminderTag(reminderID: 1, tagID: 4) - ReminderTag(reminderID: 1, tagID: 7) - ReminderTag(reminderID: 2, tagID: 3) - ReminderTag(reminderID: 2, tagID: 4) - ReminderTag(reminderID: 3, tagID: 7) - ReminderTag(reminderID: 4, tagID: 1) - ReminderTag(reminderID: 4, tagID: 2) + Tag(id: tagIDs[0], title: "car") + Tag(id: tagIDs[1], title: "kids") + Tag(id: tagIDs[2], title: "someday") + Tag(id: tagIDs[3], title: "optional") + Tag(id: tagIDs[4], title: "social") + Tag(id: tagIDs[5], title: "night") + Tag(id: tagIDs[6], title: "adulting") + ReminderTag(reminderID: reminderIDs[0], tagID: tagIDs[2]) + ReminderTag(reminderID: reminderIDs[0], tagID: tagIDs[3]) + ReminderTag(reminderID: reminderIDs[0], tagID: tagIDs[6]) + ReminderTag(reminderID: reminderIDs[1], tagID: tagIDs[2]) + ReminderTag(reminderID: reminderIDs[1], tagID: tagIDs[3]) + ReminderTag(reminderID: reminderIDs[2], tagID: tagIDs[6]) + ReminderTag(reminderID: reminderIDs[3], tagID: tagIDs[0]) + ReminderTag(reminderID: reminderIDs[3], tagID: tagIDs[1]) } } } diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index 35de7334..a67cd53b 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -3,7 +3,7 @@ import SharingGRDB import SwiftUI struct SearchRemindersView: View { - @FetchOne var completedCount: Int = 0 + @State @FetchOne var completedCount: Int = 0 @State @FetchAll var reminders: [ReminderState] let searchText: String @@ -62,7 +62,7 @@ struct SearchRemindersView: View { if searchText.isEmpty { showCompletedInSearchResults = false } - try await $completedCount.load( + try await $completedCount.wrappedValue.load( Reminder.searching(searchText) .where(\.isCompleted) .count(), diff --git a/Examples/Reminders/TagRow.swift b/Examples/Reminders/TagRow.swift index ae9c7e27..ffe38591 100644 --- a/Examples/Reminders/TagRow.swift +++ b/Examples/Reminders/TagRow.swift @@ -34,7 +34,7 @@ struct TagRow: View { #Preview { NavigationStack { List { - TagRow(tag: Tag(id: 1, title: "optional")) + TagRow(tag: Tag(id: UUID(1), title: "optional")) } } } diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index 8ba5b1a1..4653ce33 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -11,26 +11,26 @@ struct TagsView: View { Form { let selectedTagIDs = Set(selectedTags.map(\.id)) if !tags.top.isEmpty { - Section { - ForEach(tags.top, id: \.id) { tag in - TagView( - isSelected: selectedTagIDs.contains(tag.id), - selectedTags: $selectedTags, - tag: tag - ) - } - } header: { - Text("Top tags") - } +// Section { +// ForEach(tags.top, id: \.id) { tag in +// TagView( +// isSelected: selectedTagIDs.contains(tag.id), +// selectedTags: $selectedTags, +// tag: tag +// ) +// } +// } header: { +// Text("Top tags") +// } } if !tags.rest.isEmpty { Section { - ForEach(tags.rest, id: \.id) { tag in - TagView( - isSelected: selectedTagIDs.contains(tag.id), - selectedTags: $selectedTags, - tag: tag - ) + ForEach(tags.rest) { tag in +// TagView( +// isSelected: selectedTagIDs.contains(tag.id), +// selectedTags: $selectedTags, +// tag: tag +// ) } } } From b93177f3e9b183a7b80fcbdc23db2488f694b9f0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 31 May 2025 10:37:29 -0700 Subject: [PATCH 02/21] modern --- .gitignore | 1 + Examples/Reminders/ReminderForm.swift | 268 +++++++++++---------- Examples/Reminders/RemindersApp.swift | 15 +- Examples/Reminders/RemindersDetail.swift | 192 ++++++++------- Examples/Reminders/RemindersListForm.swift | 64 ++--- Examples/Reminders/RemindersLists.swift | 224 ++++++++++------- Examples/Reminders/Schema.swift | 70 ++++-- Examples/Reminders/TagsForm.swift | 32 +-- 8 files changed, 494 insertions(+), 372 deletions(-) diff --git a/.gitignore b/.gitignore index 71d4f9f3..eee445d9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ xcuserdata/ DerivedData/ .swiftpm .netrc +*.sqlite \ No newline at end of file diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index d864360b..a44e7118 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -4,6 +4,7 @@ import SwiftUI struct ReminderFormView: View { @FetchAll(RemindersList.order(by: \.title)) var remindersLists + @FetchAll var remindersSections: [RemindersSection] @State var isPresentingTagsPopover = false @State var remindersList: RemindersList @@ -20,6 +21,11 @@ struct ReminderFormView: View { } else { reminder = Reminder.Draft(remindersListID: remindersList.id) } + _remindersSections = FetchAll( + RemindersSection + .where { $0.remindersListID.eq(remindersList.id) } + .order(by: \.title) + ) } var body: some View { @@ -38,126 +44,142 @@ struct ReminderFormView: View { .lineLimit(4) .padding([.leading, .trailing], -5) -// Section { -// Button { -// isPresentingTagsPopover = true -// } label: { -// HStack { -// Image(systemName: "number.square.fill") -// .font(.title) -// .foregroundStyle(.gray) -// Text("Tags") -// .foregroundStyle(Color(.label)) -// Spacer() -// if let tagsDetail { -// tagsDetail -// .lineLimit(1) -// .truncationMode(.tail) -// .font(.callout) -// .foregroundStyle(.gray) -// } -// Image(systemName: "chevron.right") -// } -// } -// } -// .popover(isPresented: $isPresentingTagsPopover) { -// NavigationStack { -// TagsView(selectedTags: $selectedTags) -// } -// } - -// Section { -// Toggle(isOn: $reminder.isDateSet.animation()) { -// HStack { -// Image(systemName: "calendar.circle.fill") -// .font(.title) -// .foregroundStyle(.red) -// Text("Date") -// } -// } -// if let dueDate = reminder.dueDate { -// DatePicker( -// "", -// selection: $reminder.dueDate[coalesce: dueDate], -// displayedComponents: [.date, .hourAndMinute] -// ) -// .padding([.top, .bottom], 2) -// } -// } - -// Section { -// Toggle(isOn: $reminder.isFlagged) { -// HStack { -// Image(systemName: "flag.circle.fill") -// .font(.title) -// .foregroundStyle(.red) -// Text("Flag") -// } -// } -// Picker(selection: $reminder.priority) { -// Text("None").tag(Priority?.none) -// Divider() -// Text("High").tag(Priority.high) -// Text("Medium").tag(Priority.medium) -// Text("Low").tag(Priority.low) -// } label: { -// HStack { -// Image(systemName: "exclamationmark.circle.fill") -// .font(.title) -// .foregroundStyle(.red) -// Text("Priority") -// } -// } -// -// Picker(selection: $remindersList) { -// ForEach(remindersLists) { remindersList in -// Text(remindersList.title) -// .tag(remindersList) -// .buttonStyle(.plain) -// } -// } label: { -// HStack { -// Image(systemName: "list.bullet.circle.fill") -// .font(.title) -// .foregroundStyle(remindersList.color) -// Text("List") -// } -// } -// .onChange(of: remindersList) { -// reminder.remindersListID = remindersList.id -// } -// } -// } -// .padding(.top, -28) -// .task { -// guard let reminderID = reminder.id -// else { return } -// do { -// selectedTags = try await database.read { db in -// try Tag -// .order(by: \.title) -// .join(ReminderTag.all) { $0.id.eq($1.tagID) } -// .where { $1.reminderID.eq(reminderID) } -// .select { tag, _ in tag } -// .fetchAll(db) -// } -// } catch { -// selectedTags = [] -// reportIssue(error) -// } -// } -// .navigationBarTitleDisplayMode(.inline) -// .toolbar { -// ToolbarItem { -// Button(action: saveButtonTapped) { -// Text("Save") -// } -// } -// ToolbarItem(placement: .cancellationAction) { -// Button("Cancel") { -// dismiss() -// } -// } + Section { + Button { + isPresentingTagsPopover = true + } label: { + HStack { + Image(systemName: "number.square.fill") + .font(.title) + .foregroundStyle(.gray) + Text("Tags") + .foregroundStyle(Color(.label)) + Spacer() + if let tagsDetail { + tagsDetail + .lineLimit(1) + .truncationMode(.tail) + .font(.callout) + .foregroundStyle(.gray) + } + Image(systemName: "chevron.right") + } + } + } + .popover(isPresented: $isPresentingTagsPopover) { + NavigationStack { + TagsView(selectedTags: $selectedTags) + } + } + + Section { + Toggle(isOn: $reminder.isDateSet.animation()) { + HStack { + Image(systemName: "calendar.circle.fill") + .font(.title) + .foregroundStyle(.red) + Text("Date") + } + } + if let dueDate = reminder.dueDate { + DatePicker( + "", + selection: $reminder.dueDate[coalesce: dueDate], + displayedComponents: [.date, .hourAndMinute] + ) + .padding([.top, .bottom], 2) + } + } + + Section { + Toggle(isOn: $reminder.isFlagged) { + HStack { + Image(systemName: "flag.circle.fill") + .font(.title) + .foregroundStyle(.red) + Text("Flag") + } + } + Picker(selection: $reminder.priority) { + Text("None").tag(Priority?.none) + Divider() + Text("High").tag(Priority.high) + Text("Medium").tag(Priority.medium) + Text("Low").tag(Priority.low) + } label: { + HStack { + Image(systemName: "exclamationmark.circle.fill") + .font(.title) + .foregroundStyle(.red) + Text("Priority") + } + } + + Picker(selection: $remindersList) { + ForEach(remindersLists) { remindersList in + Text(remindersList.title) + .tag(remindersList) + .buttonStyle(.plain) + } + } label: { + HStack { + Image(systemName: "arrowtriangle.up.circle.fill") + .font(.title) + .foregroundStyle(remindersList.color) + Text("List") + } + } + .onChange(of: remindersList) { + reminder.remindersListID = remindersList.id + } + + Picker(selection: $reminder.remindersSectionID) { + Text("None").tag(RemindersSection.ID?.none) + Divider() + ForEach(remindersSections) { remindersSection in + Text(remindersSection.title) + .tag(remindersSection.id) + } + } label: { + HStack { + Image(systemName: "list.bullet.circle.fill") + .font(.title) + .foregroundStyle(Color.blue) + Text("Section") + } + } + } + } + .padding(.top, -28) + .task { + guard let reminderID = reminder.id + else { return } + do { + selectedTags = try await database.read { db in + try Tag + .order(by: \.title) + .join(ReminderTag.all) { $0.id.eq($1.tagID) } + .where { $1.reminderID.eq(reminderID) } + .select { tag, _ in tag } + .fetchAll(db) + } + } catch { + selectedTags = [] + reportIssue(error) + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem { + Button(action: saveButtonTapped) { + Text("Save") + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } } } @@ -171,7 +193,11 @@ struct ReminderFormView: View { private func saveButtonTapped() { withErrorReporting { try database.write { db in - let reminderID = try Reminder.upsert(reminder).returning(\.id).fetchOne(db)! + var reminder = reminder + reminder.id = reminder.id ?? UUID() + let q = Reminder.upsert(reminder).returning(\.id) + print(q.queryFragment.debugDescription) + let reminderID = try q.fetchOne(db)! try ReminderTag .where { $0.reminderID.eq(reminderID) } .delete() diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 57443a92..4e0f9fd3 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -1,18 +1,29 @@ import SharingGRDB import SwiftUI +import TipKit @main struct RemindersApp: App { + @Dependency(\.context) var context + init() { + guard context == .live + else { return } + try! prepareDependencies { $0.defaultDatabase = try Reminders.appDatabase() } + withErrorReporting { + try Tips.configure() + } } var body: some Scene { WindowGroup { - NavigationStack { - RemindersListsView() + if context == .live { + NavigationStack { + RemindersListsView() + } } } } diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 5dc72c39..be08ac43 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -3,7 +3,7 @@ import SharingGRDB import SwiftUI struct RemindersDetailView: View { - @FetchAll private var reminderStates: [ReminderState] + @FetchAll private var sectionRows: [SectionState] @AppStorage private var ordering: Ordering @AppStorage private var showCompleted: Bool @@ -21,7 +21,7 @@ struct RemindersDetailView: View { wrappedValue: detailType == .completed, "show_completed_list_\(detailType.id)" ) - _reminderStates = FetchAll(remindersQuery, animation: .default) + _sectionRows = FetchAll(sectionsQuery, animation: .default) } var body: some View { @@ -35,16 +35,28 @@ struct RemindersDetailView: View { } } .listRowSeparator(.hidden) - ForEach(reminderStates) { reminderState in - ReminderRow( - color: detailType.color, - isPastDue: reminderState.isPastDue, - notes: reminderState.notes, - reminder: reminderState.reminder, - remindersList: reminderState.remindersList, - showCompleted: showCompleted, - tags: reminderState.tags - ) + ForEach(sectionRows) { sectionRow in + Section { + ForEach(sectionRow.reminders) { reminder in + ReminderRow( + color: detailType.color, + isPastDue: false, + //reminderState.isPastDue, + notes: "", + //reminderState.notes, + reminder: reminder, + //reminderState.reminder, + remindersList: sectionRow.remindersList ?? RemindersList(id: UUID()),// reminderState.remindersList, + showCompleted: showCompleted, + tags: [] //reminderState.tags + ) + } + } header: { + Text(sectionRow.remindersSection?.title ?? "Others") + .font(.system(.title, design: .rounded, weight: .bold)) + .foregroundStyle(sectionRow.remindersSection == nil ? Color.secondary : Color.primary) + .padding([.top, .bottom], 6) + } } .onMove { indexSet, index in move(from: indexSet, to: index) @@ -130,25 +142,25 @@ struct RemindersDetailView: View { } func move(from source: IndexSet, to destination: Int) { - withErrorReporting { - try database.write { db in - var ids = reminderStates.map(\.reminder.id) - ids.move(fromOffsets: source, toOffset: destination) - try Reminder - .where { $0.id.in(ids) } - .update { - let ids = Array(ids.enumerated()) - let (first, rest) = (ids.first!, ids.dropFirst()) - $0.position = - rest - .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in - cases.when(id.element, then: id.offset) - } - .else($0.position) - } - .execute(db) - } - } +// withErrorReporting { +// try database.write { db in +// var ids = reminderStates.map(\.reminder.id) +// ids.move(fromOffsets: source, toOffset: destination) +// try Reminder +// .where { $0.id.in(ids) } +// .update { +// let ids = Array(ids.enumerated()) +// let (first, rest) = (ids.first!, ids.dropFirst()) +// $0.position = +// rest +// .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in +// cases.when(id.element, then: id.offset) +// } +// .else($0.position) +// } +// .execute(db) +// } +// } ordering = .manual } @@ -180,58 +192,68 @@ struct RemindersDetailView: View { } private func updateQuery() async throws { - try await $reminderStates.load(remindersQuery) + try await $sectionRows.load(sectionsQuery) } - fileprivate var remindersQuery: some StructuredQueriesCore.Statement { - Reminder.select { - ReminderState.Columns.init( - reminder: $0, - remindersList: #sql(""), - isPastDue: #sql(""), - notes: #sql(""), - tags: #sql("") - ) - } -// let query = -// Reminder -// .where { -// if !showCompleted { -// !$0.isCompleted -// } -// } -// .order { $0.isCompleted } -// .order { -// switch ordering { -// case .dueDate: $0.dueDate.asc(nulls: .last) -// case .manual: $0.position -// case .priority: ($0.priority.desc(), $0.isFlagged.desc()) -// case .title: $0.title -// } -// } -// .withTags -// .where { reminder, _, tag in -// switch detailType { -// case .all: !reminder.isCompleted -// case .completed: reminder.isCompleted -// case .flagged: reminder.isFlagged -// case .list(let list): reminder.remindersListID.eq(list.id) -// case .scheduled: reminder.isScheduled -// case .tags(let tags): tag.id.ifnull(0).in(tags.map(\.id)) -// case .today: reminder.isToday -// } -// } -// .join(RemindersList.all) { $0.remindersListID.eq($3.id) } -// .select { -// ReminderState.Columns( -// reminder: $0, -// remindersList: $3, -// isPastDue: $0.isPastDue, -// notes: $0.inlineNotes.substr(0, 200), -// tags: #sql("\($2.jsonNames)") -// ) -// } -// return query + fileprivate var sectionsQuery: some StructuredQueries.Statement { + let query = RemindersSection + // TODO: eq is not defined on (_, ?) ? + .fullJoin(remindersQuery) { +// (showCompleted || !$1.isCompleted) +// && + $1.remindersSectionID.eq($0.id) + } + .leftJoin(ReminderTag.all) { $1.id.eq($2.reminderID) } + .leftJoin(Tag.all) { $2.tagID.eq($3.id) } + .where { _, reminder, _, tag in + switch detailType { + case .all: #sql("NOT \(reminder.isCompleted)") + case .completed: #sql("\(reminder.isCompleted)") + case .flagged: #sql("\(reminder.isFlagged)") + case .list(let list): + #sql("\(reminder.remindersListID.eq(list.id)) OR \(reminder.remindersListID) IS NULL") + case .scheduled: #sql("\(reminder.isScheduled)") + case .tags(let tags): tag.id.ifnull(UUID(0)).in(tags.map(\.id)) + case .today: #sql("\(reminder.isToday)") + } + } + .leftJoin(RemindersList.all) { $1.remindersListID.eq($4.id) } + .select { remindersSection, reminder, _, tag, remindersList in + SectionState.Columns( + remindersList: remindersList, + remindersSection: remindersSection, + reminders: #sql("\(reminder.jsonGroupArray(filter: reminder.id.isNot(nil)))") + ) + } + .group { _, reminder, _, _, _ in reminder.remindersSectionID } + return query + } + + fileprivate var remindersQuery: some StructuredQueries.SelectStatementOf { + Reminder + .where { + if !showCompleted { + !$0.isCompleted + } + } + .order { $0.isCompleted } + .order { + switch ordering { + case .dueDate: $0.dueDate.asc(nulls: .last) + case .manual: $0.position + case .priority: ($0.priority.desc(), $0.isFlagged.desc()) + case .title: $0.title + } + } + } + + @Selection + fileprivate struct SectionState: Identifiable { + var id: RemindersSection.ID? { remindersSection?.id } + let remindersList: RemindersList? + let remindersSection: RemindersSection? + @Column(as: [Reminder].JSONRepresentation.self) + let reminders: [Reminder] } @Selection @@ -290,18 +312,18 @@ extension RemindersDetailView.DetailType { struct RemindersDetailPreview: PreviewProvider { static var previews: some View { let (remindersList, tag) = try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() + $0.defaultDatabase = try Reminders.appDatabase(seed: true) return try $0.defaultDatabase.read { db in ( - try RemindersList.all.fetchOne(db)!, + try RemindersList.limit(1, offset: 2).fetchOne(db)!, try Tag.all.fetchOne(db)! ) } } let detailTypes: [RemindersDetailView.DetailType] = [ - .all, +// .all, .list(remindersList), - .tags([tag]), +// .tags([tag]), ] ForEach(detailTypes, id: \.self) { detailType in NavigationStack { diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 1f75672b..37bb2acc 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -14,39 +14,39 @@ struct RemindersListForm: View { var body: some View { Form { -// Section { -// VStack { -// TextField("List Name", text: $remindersList.title) -// .font(.system(.title2, design: .rounded, weight: .bold)) -// .foregroundStyle(remindersList.color) -// .multilineTextAlignment(.center) -// .padding() -// .textFieldStyle(.plain) -// } -// .background(Color(.secondarySystemBackground)) -// .clipShape(.buttonBorder) -// } -// ColorPicker("Color", selection: $remindersList.color) + Section { + VStack { + TextField("List Name", text: $remindersList.title) + .font(.system(.title2, design: .rounded, weight: .bold)) + .foregroundStyle(remindersList.color) + .multilineTextAlignment(.center) + .padding() + .textFieldStyle(.plain) + } + .background(Color(.secondarySystemBackground)) + .clipShape(.buttonBorder) + } + ColorPicker("Color", selection: $remindersList.color) + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem { + Button("Save") { + withErrorReporting { + try database.write { db in + try RemindersList.upsert(remindersList) + .execute(db) + } + } + dismiss() + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } } -// .navigationBarTitleDisplayMode(.inline) -// .toolbar { -// ToolbarItem { -// Button("Save") { -// withErrorReporting { -// try database.write { db in -// try RemindersList.upsert(remindersList) -// .execute(db) -// } -// } -// dismiss() -// } -// } -// ToolbarItem(placement: .cancellationAction) { -// Button("Cancel") { -// dismiss() -// } -// } -// } } } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 14fffae4..f19d6d83 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -1,5 +1,6 @@ import SharingGRDB import SwiftUI +import TipKit struct RemindersListsView: View { @FetchAll( @@ -39,6 +40,7 @@ struct RemindersListsView: View { @State private var destination: Destination? @State private var remindersDetailType: RemindersDetailView.DetailType? @State private var searchText = "" + @State private var seedDatabaseTip: SeedDatabaseTip? @Dependency(\.defaultDatabase) private var database @@ -67,109 +69,133 @@ struct RemindersListsView: View { var body: some View { List { if searchText.isEmpty { -// Section { -// Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 16) { -// GridRow { -// ReminderGridCell( -// color: .blue, -// count: stats.todayCount, -// iconName: "calendar.circle.fill", -// title: "Today" -// ) { -// remindersDetailType = .today -// } -// ReminderGridCell( -// color: .red, -// count: stats.scheduledCount, -// iconName: "calendar.circle.fill", -// title: "Scheduled" -// ) { -// remindersDetailType = .scheduled -// } -// } -// GridRow { -// ReminderGridCell( -// color: .gray, -// count: stats.allCount, -// iconName: "tray.circle.fill", -// title: "All" -// ) { -// remindersDetailType = .all -// } -// ReminderGridCell( -// color: .orange, -// count: stats.flaggedCount, -// iconName: "flag.circle.fill", -// title: "Flagged" -// ) { -// remindersDetailType = .flagged -// } -// } -// GridRow { -// ReminderGridCell( -// color: .gray, -// count: nil, -// iconName: "checkmark.circle.fill", -// title: "Completed" -// ) { -// remindersDetailType = .completed -// } -// } -// } -// .buttonStyle(.plain) -// .listRowBackground(Color.clear) -// .padding([.leading, .trailing], -20) -// } + Section { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 16) { + GridRow { + ReminderGridCell( + color: .blue, + count: stats.todayCount, + iconName: "calendar.circle.fill", + title: "Today" + ) { + remindersDetailType = .today + } + ReminderGridCell( + color: .red, + count: stats.scheduledCount, + iconName: "calendar.circle.fill", + title: "Scheduled" + ) { + remindersDetailType = .scheduled + } + } + GridRow { + ReminderGridCell( + color: .gray, + count: stats.allCount, + iconName: "tray.circle.fill", + title: "All" + ) { + remindersDetailType = .all + } + ReminderGridCell( + color: .orange, + count: stats.flaggedCount, + iconName: "flag.circle.fill", + title: "Flagged" + ) { + remindersDetailType = .flagged + } + } + GridRow { + ReminderGridCell( + color: .gray, + count: nil, + iconName: "checkmark.circle.fill", + title: "Completed" + ) { + remindersDetailType = .completed + } + } + } + .buttonStyle(.plain) + .listRowBackground(Color.clear) + .padding([.leading, .trailing], -20) + } -// Section { -// ForEach(remindersLists) { state in -// NavigationLink { -// RemindersDetailView(detailType: .list(state.remindersList)) -// } label: { -// RemindersListRow( -// remindersCount: state.remindersCount, -// remindersList: state.remindersList -// ) -// } -// } -// .onMove { indexSet, index in -// move(from: indexSet, to: index) -// } -// } header: { -// Text("My Lists") -// .font(.system(.title2, design: .rounded, weight: .bold)) -// .foregroundStyle(Color(.label)) -// .textCase(nil) -// .padding(.top, -16) -// .padding([.leading, .trailing], 4) -// } -// .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) + Section { + ForEach(remindersLists) { state in + NavigationLink { + RemindersDetailView(detailType: .list(state.remindersList)) + } label: { + RemindersListRow( + remindersCount: state.remindersCount, + remindersList: state.remindersList + ) + } + } + .onMove { indexSet, index in + move(from: indexSet, to: index) + } + } header: { + Text("My Lists") + .font(.system(.title2, design: .rounded, weight: .bold)) + .foregroundStyle(Color(.label)) + .textCase(nil) + .padding(.top, -16) + .padding([.leading, .trailing], 4) + } + .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) -// Section { -// ForEach(tags) { tag in -// NavigationLink { -// RemindersDetailView(detailType: .tags([tag])) -// } label: { -// TagRow(tag: tag) -// } -// } -// } header: { -// Text("Tags") -// .font(.system(.title2, design: .rounded, weight: .bold)) -// .foregroundStyle(Color(.label)) -// .textCase(nil) -// .padding(.top, -16) -// .padding([.leading, .trailing], 4) -// } -// .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) + Section { + ForEach(tags) { tag in + NavigationLink { + RemindersDetailView(detailType: .tags([tag])) + } label: { + TagRow(tag: tag) + } + } + } header: { + Text("Tags") + .font(.system(.title2, design: .rounded, weight: .bold)) + .foregroundStyle(Color(.label)) + .textCase(nil) + .padding(.top, -16) + .padding([.leading, .trailing], 4) + } + .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) } else { SearchRemindersView(searchText: searchText) } } + .task { + if remindersLists.isEmpty { + seedDatabaseTip = SeedDatabaseTip() + } + } // NB: This explicit view identity works around a bug with 'List' view state not getting reset. .id(searchText) .listStyle(.insetGrouped) .toolbar { + #if DEBUG + ToolbarItem(placement: .primaryAction) { + Menu { + Button { + withErrorReporting { + try database.write { db in + try db.seedSampleData() + } + } + } label: { + Text("Seed data") + Image(systemName: "leaf") + } + } label: { + Image(systemName: "ellipsis.circle") + } + .popoverTip(seedDatabaseTip) + } + #endif ToolbarItem(placement: .bottomBar) { HStack { Button { @@ -278,6 +304,18 @@ private struct ReminderGridCell: View { } } +private struct SeedDatabaseTip: Tip { + var title: Text { + Text("Seed Sample Data") + } + var message: Text? { + Text("Tap here to quickly populate the app with test data.") + } + var image: Image? { + Image(systemName: "leaf") + } +} + #Preview { let _ = try! prepareDependencies { $0.defaultDatabase = try Reminders.appDatabase() diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 3ab4a6df..c67f946f 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -6,7 +6,7 @@ import SwiftUI @Table struct RemindersList: Hashable, Identifiable { - let id: UUID + var id: UUID @Column(as: Color.HexRepresentation.self) var color = Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255) var position = 0 @@ -14,14 +14,15 @@ struct RemindersList: Hashable, Identifiable { } @Table -struct ReminderSection: Hashable, Identifiable { - let id: UUID +struct RemindersSection: Hashable, Identifiable { + var id: UUID + var remindersListID: RemindersList.ID var title = "" } @Table -struct Reminder: Equatable, Identifiable { - let id: UUID +struct Reminder: Codable, Equatable, Identifiable { + var id: UUID var dueDate: Date? var isCompleted = false var isFlagged = false @@ -29,7 +30,7 @@ struct Reminder: Equatable, Identifiable { var priority: Priority? var remindersListID: RemindersList.ID var position = 0 - var sectionID: Section.ID? + var remindersSectionID: RemindersSection.ID? var title = "" } @@ -61,7 +62,7 @@ extension Reminder.TableColumns { } } -enum Priority: Int, QueryBindable { +enum Priority: Int, Codable, QueryBindable { case low = 1 case medium case high @@ -69,7 +70,7 @@ enum Priority: Int, QueryBindable { @Table struct Tag: Hashable, Identifiable { - let id: UUID + var id: UUID var title = "" } @@ -92,7 +93,7 @@ struct ReminderTag: Hashable, Identifiable { var id: Self { self } } -func appDatabase() throws -> any DatabaseWriter { +func appDatabase(seed: Bool = false) throws -> any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter var configuration = Configuration() @@ -133,9 +134,12 @@ func appDatabase() throws -> any DatabaseWriter { .execute(db) try #sql( """ - CREATE TABLE "sections" ( + CREATE TABLE "remindersSections" ( "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "title" TEXT NOT NULL + "remindersListID" TEXT, + "title" TEXT NOT NULL, + + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """ ) @@ -150,12 +154,12 @@ func appDatabase() throws -> any DatabaseWriter { "notes" TEXT, "position" INTEGER NOT NULL DEFAULT 0, "priority" INTEGER, - "remindersListID" INTEGER NOT NULL, - "sectionID" TEXT, + "remindersListID" TEXT NOT NULL, + "remindersSectionID" TEXT, "title" TEXT NOT NULL, FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE, - FOREIGN KEY("sectionID") REFERENCES "sections"("id") ON DELETE CASCADE + FOREIGN KEY("remindersSectionID") REFERENCES "remindersSections"("id") ON DELETE SET NULL ) STRICT """ ) @@ -182,6 +186,7 @@ func appDatabase() throws -> any DatabaseWriter { ) .execute(db) } + try database.write { db in try #sql( """ @@ -209,13 +214,12 @@ func appDatabase() throws -> any DatabaseWriter { .execute(db) } - #if DEBUG && targetEnvironment(simulator) - if context != .test { - migrator.registerMigration("Seed sample data") { db in - try db.seedSampleData() - } + if seed { + try database.write { db in + try db.seedSampleData() } - #endif + } + try migrator.migrate(database) return database @@ -226,9 +230,10 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") #if DEBUG extension Database { func seedSampleData() throws { - let remindersListIDs = (1...3).map { _ in UUID() } - let reminderIDs = (1...10).map { _ in UUID() } - let tagIDs = (1...7).map { _ in UUID() } + let remindersListIDs = (0...2).map { _ in UUID() } + let remindersSectionsIDs = (0...1).map { _ in UUID() } + let reminderIDs = (0...10).map { _ in UUID() } + let tagIDs = (0...6).map { _ in UUID() } try seed { RemindersList( id: remindersListIDs[0], @@ -245,6 +250,16 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), title: "Business" ) + RemindersSection( + id: remindersSectionsIDs[0], + remindersListID: remindersListIDs[2], + title: "Phase 1" + ) + RemindersSection( + id: remindersSectionsIDs[1], + remindersListID: remindersListIDs[2], + title: "Phase 2" + ) Reminder( id: reminderIDs[0], notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", @@ -312,6 +327,7 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") Changing payroll company """, remindersListID: remindersListIDs[2], + remindersSectionID: remindersSectionsIDs[0], title: "Call accountant" ) Reminder( @@ -320,8 +336,16 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") isCompleted: true, priority: .medium, remindersListID: remindersListIDs[2], + remindersSectionID: remindersSectionsIDs[1], title: "Send weekly emails" ) + Reminder( + id: reminderIDs[10], + dueDate: Date().addingTimeInterval(60 * 60 * 24 * 2), + isCompleted: false, + remindersListID: remindersListIDs[2], + title: "Prepare for WWDC" + ) Tag(id: tagIDs[0], title: "car") Tag(id: tagIDs[1], title: "kids") Tag(id: tagIDs[2], title: "someday") diff --git a/Examples/Reminders/TagsForm.swift b/Examples/Reminders/TagsForm.swift index 4653ce33..0c576c16 100644 --- a/Examples/Reminders/TagsForm.swift +++ b/Examples/Reminders/TagsForm.swift @@ -11,26 +11,26 @@ struct TagsView: View { Form { let selectedTagIDs = Set(selectedTags.map(\.id)) if !tags.top.isEmpty { -// Section { -// ForEach(tags.top, id: \.id) { tag in -// TagView( -// isSelected: selectedTagIDs.contains(tag.id), -// selectedTags: $selectedTags, -// tag: tag -// ) -// } -// } header: { -// Text("Top tags") -// } + Section { + ForEach(tags.top, id: \.id) { tag in + TagView( + isSelected: selectedTagIDs.contains(tag.id), + selectedTags: $selectedTags, + tag: tag + ) + } + } header: { + Text("Top tags") + } } if !tags.rest.isEmpty { Section { ForEach(tags.rest) { tag in -// TagView( -// isSelected: selectedTagIDs.contains(tag.id), -// selectedTags: $selectedTags, -// tag: tag -// ) + TagView( + isSelected: selectedTagIDs.contains(tag.id), + selectedTags: $selectedTags, + tag: tag + ) } } } From 23ae9ede02b5e9c2230a6e2cb4e4c8d68fee7644 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 31 May 2025 10:40:13 -0700 Subject: [PATCH 03/21] wip --- Examples/Reminders/ReminderForm.swift | 30 +---- Examples/Reminders/RemindersDetail.swift | 155 +++++++++-------------- Examples/Reminders/Schema.swift | 41 +----- 3 files changed, 67 insertions(+), 159 deletions(-) diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index a44e7118..6f94e7ad 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -4,7 +4,6 @@ import SwiftUI struct ReminderFormView: View { @FetchAll(RemindersList.order(by: \.title)) var remindersLists - @FetchAll var remindersSections: [RemindersSection] @State var isPresentingTagsPopover = false @State var remindersList: RemindersList @@ -21,11 +20,6 @@ struct ReminderFormView: View { } else { reminder = Reminder.Draft(remindersListID: remindersList.id) } - _remindersSections = FetchAll( - RemindersSection - .where { $0.remindersListID.eq(remindersList.id) } - .order(by: \.title) - ) } var body: some View { @@ -123,7 +117,7 @@ struct ReminderFormView: View { } } label: { HStack { - Image(systemName: "arrowtriangle.up.circle.fill") + Image(systemName: "list.bullet.circle.fill") .font(.title) .foregroundStyle(remindersList.color) Text("List") @@ -132,22 +126,6 @@ struct ReminderFormView: View { .onChange(of: remindersList) { reminder.remindersListID = remindersList.id } - - Picker(selection: $reminder.remindersSectionID) { - Text("None").tag(RemindersSection.ID?.none) - Divider() - ForEach(remindersSections) { remindersSection in - Text(remindersSection.title) - .tag(remindersSection.id) - } - } label: { - HStack { - Image(systemName: "list.bullet.circle.fill") - .font(.title) - .foregroundStyle(Color.blue) - Text("Section") - } - } } } .padding(.top, -28) @@ -193,11 +171,7 @@ struct ReminderFormView: View { private func saveButtonTapped() { withErrorReporting { try database.write { db in - var reminder = reminder - reminder.id = reminder.id ?? UUID() - let q = Reminder.upsert(reminder).returning(\.id) - print(q.queryFragment.debugDescription) - let reminderID = try q.fetchOne(db)! + let reminderID = try Reminder.upsert(reminder).returning(\.id).fetchOne(db)! try ReminderTag .where { $0.reminderID.eq(reminderID) } .delete() diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index be08ac43..604517d8 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -3,7 +3,7 @@ import SharingGRDB import SwiftUI struct RemindersDetailView: View { - @FetchAll private var sectionRows: [SectionState] + @FetchAll private var reminderStates: [ReminderState] @AppStorage private var ordering: Ordering @AppStorage private var showCompleted: Bool @@ -21,7 +21,7 @@ struct RemindersDetailView: View { wrappedValue: detailType == .completed, "show_completed_list_\(detailType.id)" ) - _sectionRows = FetchAll(sectionsQuery, animation: .default) + _reminderStates = FetchAll(remindersQuery, animation: .default) } var body: some View { @@ -35,28 +35,16 @@ struct RemindersDetailView: View { } } .listRowSeparator(.hidden) - ForEach(sectionRows) { sectionRow in - Section { - ForEach(sectionRow.reminders) { reminder in - ReminderRow( - color: detailType.color, - isPastDue: false, - //reminderState.isPastDue, - notes: "", - //reminderState.notes, - reminder: reminder, - //reminderState.reminder, - remindersList: sectionRow.remindersList ?? RemindersList(id: UUID()),// reminderState.remindersList, - showCompleted: showCompleted, - tags: [] //reminderState.tags - ) - } - } header: { - Text(sectionRow.remindersSection?.title ?? "Others") - .font(.system(.title, design: .rounded, weight: .bold)) - .foregroundStyle(sectionRow.remindersSection == nil ? Color.secondary : Color.primary) - .padding([.top, .bottom], 6) - } + ForEach(reminderStates) { reminderState in + ReminderRow( + color: detailType.color, + isPastDue: reminderState.isPastDue, + notes: reminderState.notes, + reminder: reminderState.reminder, + remindersList: reminderState.remindersList, + showCompleted: showCompleted, + tags: reminderState.tags + ) } .onMove { indexSet, index in move(from: indexSet, to: index) @@ -142,25 +130,25 @@ struct RemindersDetailView: View { } func move(from source: IndexSet, to destination: Int) { -// withErrorReporting { -// try database.write { db in -// var ids = reminderStates.map(\.reminder.id) -// ids.move(fromOffsets: source, toOffset: destination) -// try Reminder -// .where { $0.id.in(ids) } -// .update { -// let ids = Array(ids.enumerated()) -// let (first, rest) = (ids.first!, ids.dropFirst()) -// $0.position = -// rest -// .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in -// cases.when(id.element, then: id.offset) -// } -// .else($0.position) -// } -// .execute(db) -// } -// } + withErrorReporting { + try database.write { db in + var ids = reminderStates.map(\.reminder.id) + ids.move(fromOffsets: source, toOffset: destination) + try Reminder + .where { $0.id.in(ids) } + .update { + let ids = Array(ids.enumerated()) + let (first, rest) = (ids.first!, ids.dropFirst()) + $0.position = + rest + .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in + cases.when(id.element, then: id.offset) + } + .else($0.position) + } + .execute(db) + } + } ordering = .manual } @@ -192,44 +180,11 @@ struct RemindersDetailView: View { } private func updateQuery() async throws { - try await $sectionRows.load(sectionsQuery) + try await $reminderStates.load(remindersQuery) } - fileprivate var sectionsQuery: some StructuredQueries.Statement { - let query = RemindersSection - // TODO: eq is not defined on (_, ?) ? - .fullJoin(remindersQuery) { -// (showCompleted || !$1.isCompleted) -// && - $1.remindersSectionID.eq($0.id) - } - .leftJoin(ReminderTag.all) { $1.id.eq($2.reminderID) } - .leftJoin(Tag.all) { $2.tagID.eq($3.id) } - .where { _, reminder, _, tag in - switch detailType { - case .all: #sql("NOT \(reminder.isCompleted)") - case .completed: #sql("\(reminder.isCompleted)") - case .flagged: #sql("\(reminder.isFlagged)") - case .list(let list): - #sql("\(reminder.remindersListID.eq(list.id)) OR \(reminder.remindersListID) IS NULL") - case .scheduled: #sql("\(reminder.isScheduled)") - case .tags(let tags): tag.id.ifnull(UUID(0)).in(tags.map(\.id)) - case .today: #sql("\(reminder.isToday)") - } - } - .leftJoin(RemindersList.all) { $1.remindersListID.eq($4.id) } - .select { remindersSection, reminder, _, tag, remindersList in - SectionState.Columns( - remindersList: remindersList, - remindersSection: remindersSection, - reminders: #sql("\(reminder.jsonGroupArray(filter: reminder.id.isNot(nil)))") - ) - } - .group { _, reminder, _, _, _ in reminder.remindersSectionID } - return query - } - - fileprivate var remindersQuery: some StructuredQueries.SelectStatementOf { + fileprivate var remindersQuery: some StructuredQueriesCore.Statement { + let query = Reminder .where { if !showCompleted { @@ -239,21 +194,35 @@ struct RemindersDetailView: View { .order { $0.isCompleted } .order { switch ordering { - case .dueDate: $0.dueDate.asc(nulls: .last) + case .dueDate: $0.dueDate case .manual: $0.position case .priority: ($0.priority.desc(), $0.isFlagged.desc()) case .title: $0.title } } - } - - @Selection - fileprivate struct SectionState: Identifiable { - var id: RemindersSection.ID? { remindersSection?.id } - let remindersList: RemindersList? - let remindersSection: RemindersSection? - @Column(as: [Reminder].JSONRepresentation.self) - let reminders: [Reminder] + .withTags + .where { reminder, _, tag in + switch detailType { + case .all: !reminder.isCompleted + case .completed: reminder.isCompleted + case .flagged: reminder.isFlagged + case .list(let list): reminder.remindersListID.eq(list.id) + case .scheduled: reminder.isScheduled + case .tags(let tags): tag.id.ifnull(UUID(0)).in(tags.map(\.id)) + case .today: reminder.isToday + } + } + .join(RemindersList.all) { $0.remindersListID.eq($3.id) } + .select { + ReminderState.Columns( + reminder: $0, + remindersList: $3, + isPastDue: $0.isPastDue, + notes: $0.inlineNotes.substr(0, 200), + tags: #sql("\($2.jsonNames)") + ) + } + return query } @Selection @@ -312,18 +281,18 @@ extension RemindersDetailView.DetailType { struct RemindersDetailPreview: PreviewProvider { static var previews: some View { let (remindersList, tag) = try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase(seed: true) + $0.defaultDatabase = try Reminders.appDatabase() return try $0.defaultDatabase.read { db in ( - try RemindersList.limit(1, offset: 2).fetchOne(db)!, + try RemindersList.all.fetchOne(db)!, try Tag.all.fetchOne(db)! ) } } let detailTypes: [RemindersDetailView.DetailType] = [ -// .all, + .all, .list(remindersList), -// .tags([tag]), + .tags([tag]), ] ForEach(detailTypes, id: \.self) { detailType in NavigationStack { diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index c67f946f..1beb8c32 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -13,13 +13,6 @@ struct RemindersList: Hashable, Identifiable { var title = "" } -@Table -struct RemindersSection: Hashable, Identifiable { - var id: UUID - var remindersListID: RemindersList.ID - var title = "" -} - @Table struct Reminder: Codable, Equatable, Identifiable { var id: UUID @@ -30,7 +23,6 @@ struct Reminder: Codable, Equatable, Identifiable { var priority: Priority? var remindersListID: RemindersList.ID var position = 0 - var remindersSectionID: RemindersSection.ID? var title = "" } @@ -93,7 +85,7 @@ struct ReminderTag: Hashable, Identifiable { var id: Self { self } } -func appDatabase(seed: Bool = false) throws -> any DatabaseWriter { +func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter var configuration = Configuration() @@ -132,18 +124,6 @@ func appDatabase(seed: Bool = false) throws -> any DatabaseWriter { """ ) .execute(db) - try #sql( - """ - CREATE TABLE "remindersSections" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), - "remindersListID" TEXT, - "title" TEXT NOT NULL, - - FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE - ) STRICT - """ - ) - .execute(db) try #sql( """ CREATE TABLE "reminders" ( @@ -155,11 +135,9 @@ func appDatabase(seed: Bool = false) throws -> any DatabaseWriter { "position" INTEGER NOT NULL DEFAULT 0, "priority" INTEGER, "remindersListID" TEXT NOT NULL, - "remindersSectionID" TEXT, "title" TEXT NOT NULL, - FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE, - FOREIGN KEY("remindersSectionID") REFERENCES "remindersSections"("id") ON DELETE SET NULL + FOREIGN KEY("remindersListID") REFERENCES "remindersLists"("id") ON DELETE CASCADE ) STRICT """ ) @@ -214,7 +192,7 @@ func appDatabase(seed: Bool = false) throws -> any DatabaseWriter { .execute(db) } - if seed { + if context == .preview { try database.write { db in try db.seedSampleData() } @@ -231,7 +209,6 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") extension Database { func seedSampleData() throws { let remindersListIDs = (0...2).map { _ in UUID() } - let remindersSectionsIDs = (0...1).map { _ in UUID() } let reminderIDs = (0...10).map { _ in UUID() } let tagIDs = (0...6).map { _ in UUID() } try seed { @@ -250,16 +227,6 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), title: "Business" ) - RemindersSection( - id: remindersSectionsIDs[0], - remindersListID: remindersListIDs[2], - title: "Phase 1" - ) - RemindersSection( - id: remindersSectionsIDs[1], - remindersListID: remindersListIDs[2], - title: "Phase 2" - ) Reminder( id: reminderIDs[0], notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", @@ -327,7 +294,6 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") Changing payroll company """, remindersListID: remindersListIDs[2], - remindersSectionID: remindersSectionsIDs[0], title: "Call accountant" ) Reminder( @@ -336,7 +302,6 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") isCompleted: true, priority: .medium, remindersListID: remindersListIDs[2], - remindersSectionID: remindersSectionsIDs[1], title: "Send weekly emails" ) Reminder( From 412f6514e4660f4b6e75d5dba39896f99196bf3c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 31 May 2025 10:41:05 -0700 Subject: [PATCH 04/21] wip --- Examples/Reminders/ReminderForm.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index 6f94e7ad..564aa44b 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -4,9 +4,9 @@ import SwiftUI struct ReminderFormView: View { @FetchAll(RemindersList.order(by: \.title)) var remindersLists + @FetchOne var remindersList: RemindersList? @State var isPresentingTagsPopover = false - @State var remindersList: RemindersList @State var reminder: Reminder.Draft @State var selectedTags: [Tag] = [] @@ -14,7 +14,7 @@ struct ReminderFormView: View { @Environment(\.dismiss) var dismiss init(existingReminder: Reminder? = nil, remindersList: RemindersList) { - self.remindersList = remindersList + _remindersList = FetchOne() if let existingReminder { reminder = Reminder.Draft(existingReminder) } else { From f193fc04665b22d1420bdc0f82d4a555c34235f2 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 31 May 2025 14:53:55 -0700 Subject: [PATCH 05/21] More modernization --- Examples/Reminders/ReminderForm.swift | 13 ++-- Examples/Reminders/RemindersLists.swift | 2 +- Examples/Reminders/Schema.swift | 50 ++++++++-------- Examples/SyncUps/RecordMeeting.swift | 8 +-- Examples/SyncUps/Schema.swift | 58 +++++++++--------- Examples/SyncUps/SyncUpDetail.swift | 80 ++++++++----------------- Examples/SyncUps/SyncUpsList.swift | 64 +++++++++++++++++--- 7 files changed, 150 insertions(+), 125 deletions(-) diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index 564aa44b..9ac7cc5f 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -14,7 +14,7 @@ struct ReminderFormView: View { @Environment(\.dismiss) var dismiss init(existingReminder: Reminder? = nil, remindersList: RemindersList) { - _remindersList = FetchOne() + _remindersList = FetchOne(RemindersList.find(remindersList.id)) if let existingReminder { reminder = Reminder.Draft(existingReminder) } else { @@ -109,22 +109,25 @@ struct ReminderFormView: View { } } - Picker(selection: $remindersList) { + Picker(selection: $reminder.remindersListID) { ForEach(remindersLists) { remindersList in Text(remindersList.title) .tag(remindersList) .buttonStyle(.plain) + .tag(remindersList.id) } } label: { HStack { Image(systemName: "list.bullet.circle.fill") .font(.title) - .foregroundStyle(remindersList.color) + .foregroundStyle(remindersList?.color ?? Color.blue) Text("List") } } - .onChange(of: remindersList) { - reminder.remindersListID = remindersList.id + .task(id: reminder.remindersListID) { + await withErrorReporting { + try await $remindersList.load(RemindersList?.find(reminder.remindersListID)) + } } } } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index f19d6d83..d78be861 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -178,7 +178,7 @@ struct RemindersListsView: View { .listStyle(.insetGrouped) .toolbar { #if DEBUG - ToolbarItem(placement: .primaryAction) { + ToolbarItem(placement: .automatic) { Menu { Button { withErrorReporting { diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 1beb8c32..bb34f473 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -6,7 +6,7 @@ import SwiftUI @Table struct RemindersList: Hashable, Identifiable { - var id: UUID + let id: UUID @Column(as: Color.HexRepresentation.self) var color = Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255) var position = 0 @@ -15,7 +15,7 @@ struct RemindersList: Hashable, Identifiable { @Table struct Reminder: Codable, Equatable, Identifiable { - var id: UUID + let id: UUID var dueDate: Date? var isCompleted = false var isFlagged = false @@ -26,6 +26,18 @@ struct Reminder: Codable, Equatable, Identifiable { var title = "" } +@Table +struct Tag: Hashable, Identifiable { + let id: UUID + var title = "" +} + +enum Priority: Int, Codable, QueryBindable { + case low = 1 + case medium + case high +} + extension Reminder { static let incomplete = Self.where { !$0.isCompleted } static func searching(_ text: String) -> Where { @@ -54,18 +66,6 @@ extension Reminder.TableColumns { } } -enum Priority: Int, Codable, QueryBindable { - case low = 1 - case medium - case high -} - -@Table -struct Tag: Hashable, Identifiable { - var id: UUID - var title = "" -} - extension Tag { static let withReminders = group(by: \.id) .leftJoin(ReminderTag.all) { $0.id.eq($1.tagID) } @@ -116,7 +116,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "remindersLists" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "id" TEXT UNIQUE NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "color" INTEGER NOT NULL DEFAULT \(raw: 0x4a99_ef00), "position" INTEGER NOT NULL DEFAULT 0, "title" TEXT NOT NULL @@ -127,7 +127,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "reminders" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "id" TEXT UNIQUE NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "dueDate" TEXT, "isCompleted" INTEGER NOT NULL DEFAULT 0, "isFlagged" INTEGER NOT NULL DEFAULT 0, @@ -145,7 +145,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "tags" ( - "id" TEXT NOT NULL PRIMARY KEY DEFAULT (uuid()), + "id" TEXT UNIQUE NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL COLLATE NOCASE UNIQUE ) STRICT """ @@ -165,6 +165,14 @@ func appDatabase() throws -> any DatabaseWriter { .execute(db) } + try migrator.migrate(database) + + if context == .preview { + try database.write { db in + try db.seedSampleData() + } + } + try database.write { db in try #sql( """ @@ -192,14 +200,6 @@ func appDatabase() throws -> any DatabaseWriter { .execute(db) } - if context == .preview { - try database.write { db in - try db.seedSampleData() - } - } - - try migrator.migrate(database) - return database } diff --git a/Examples/SyncUps/RecordMeeting.swift b/Examples/SyncUps/RecordMeeting.swift index 67b3bd9b..92730cae 100644 --- a/Examples/SyncUps/RecordMeeting.swift +++ b/Examples/SyncUps/RecordMeeting.swift @@ -376,11 +376,11 @@ struct MeetingFooterView: View { NavigationStack { RecordMeetingView( model: RecordMeetingModel( - syncUp: SyncUp(id: 1, seconds: 60, theme: .bubblegum, title: "Engineering"), + syncUp: SyncUp(id: UUID(1), seconds: 60, theme: .bubblegum, title: "Engineering"), attendees: [ - Attendee(id: 1, name: "Blob", syncUpID: 1), - Attendee(id: 2, name: "Blob Jr", syncUpID: 1), - Attendee(id: 3, name: "Blob Sr", syncUpID: 1), + Attendee(id: UUID(2), name: "Blob", syncUpID: UUID(1)), + Attendee(id: UUID(3), name: "Blob Jr", syncUpID: UUID(1)), + Attendee(id: UUID(4), name: "Blob Sr", syncUpID: UUID(1)), ] ) ) diff --git a/Examples/SyncUps/Schema.swift b/Examples/SyncUps/Schema.swift index 5e4dc6ec..6c00c138 100644 --- a/Examples/SyncUps/Schema.swift +++ b/Examples/SyncUps/Schema.swift @@ -4,7 +4,7 @@ import SwiftUI @Table struct SyncUp: Hashable, Identifiable { - let id: Int + let id: UUID var seconds: Int = 60 * 5 var theme: Theme = .bubblegum var title = "" @@ -12,14 +12,14 @@ struct SyncUp: Hashable, Identifiable { @Table struct Attendee: Hashable, Identifiable { - let id: Int + let id: UUID var name = "" var syncUpID: SyncUp.ID } @Table struct Meeting: Hashable, Identifiable { - let id: Int + let id: UUID var date: Date var syncUpID: SyncUp.ID var transcript: String @@ -76,17 +76,21 @@ extension Int { } func appDatabase() throws -> any DatabaseWriter { + @Dependency(\.context) var context let database: any DatabaseWriter var configuration = Configuration() configuration.foreignKeysEnabled = true configuration.prepareDatabase { db in #if DEBUG db.trace(options: .profile) { - logger.debug("\($0.expandedDescription)") + if context == .preview { + print("\($0.expandedDescription)") + } else { + logger.debug("\($0.expandedDescription)") + } } #endif } - @Dependency(\.context) var context if context == .live { let path = URL.documentsDirectory.appending(component: "db.sqlite").path() logger.info("open \(path)") @@ -98,11 +102,11 @@ func appDatabase() throws -> any DatabaseWriter { #if DEBUG migrator.eraseDatabaseOnSchemaChange = true #endif - migrator.registerMigration("Create sync-ups table") { db in + migrator.registerMigration("Create initial tables") { db in try #sql( """ CREATE TABLE "syncUps" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "id" TEXT UNIQUE NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "seconds" INTEGER NOT NULL DEFAULT 300, "theme" TEXT NOT NULL DEFAULT \(raw: Theme.bubblegum.rawValue), "title" TEXT NOT NULL @@ -110,12 +114,10 @@ func appDatabase() throws -> any DatabaseWriter { """ ) .execute(db) - } - migrator.registerMigration("Create attendees table") { db in try #sql( """ CREATE TABLE "attendees" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "id" TEXT UNIQUE NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "name" TEXT NOT NULL, "syncUpID" INTEGER NOT NULL, @@ -124,12 +126,10 @@ func appDatabase() throws -> any DatabaseWriter { """ ) .execute(db) - } - migrator.registerMigration("Create meetings table") { db in try #sql( """ CREATE TABLE "meetings" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "id" TEXT UNIQUE NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "date" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP UNIQUE, "syncUpID" INTEGER NOT NULL, "transcript" TEXT NOT NULL, @@ -141,41 +141,40 @@ func appDatabase() throws -> any DatabaseWriter { .execute(db) } - #if DEBUG && targetEnvironment(simulator) - if context != .test { - migrator.registerMigration("Seed sample data") { db in - try db.seedSampleData() - } - } - #endif - try migrator.migrate(database) + if context == .preview { + try database.write { db in + try db.seedSampleData() + } + } + return database } private let logger = Logger(subsystem: "SyncUps", category: "Database") +#if DEBUG extension Database { - fileprivate func seedSampleData() throws { + func seedSampleData() throws { try seed { - SyncUp(id: 1, seconds: 60, theme: .appOrange, title: "Design") - SyncUp(id: 2, seconds: 60 * 10, theme: .periwinkle, title: "Engineering") - SyncUp(id: 3, seconds: 60 * 30, theme: .poppy, title: "Product") + SyncUp(id: UUID(1), seconds: 60, theme: .appOrange, title: "Design") + SyncUp(id: UUID(2), seconds: 60 * 10, theme: .periwinkle, title: "Engineering") + SyncUp(id: UUID(3), seconds: 60 * 30, theme: .poppy, title: "Product") for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { - Attendee.Draft(name: name, syncUpID: 1) + Attendee.Draft(name: name, syncUpID: UUID(1)) } for name in ["Blob", "Blob Jr"] { - Attendee.Draft(name: name, syncUpID: 2) + Attendee.Draft(name: name, syncUpID: UUID(2)) } for name in ["Blob Sr", "Blob Jr"] { - Attendee.Draft(name: name, syncUpID: 3) + Attendee.Draft(name: name, syncUpID: UUID(3)) } Meeting.Draft( date: Date().addingTimeInterval(-60 * 60 * 24 * 7), - syncUpID: 1, + syncUpID: UUID(1), transcript: """ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ @@ -188,3 +187,4 @@ extension Database { } } } +#endif diff --git a/Examples/SyncUps/SyncUpDetail.swift b/Examples/SyncUps/SyncUpDetail.swift index 4d477e7c..86751f43 100644 --- a/Examples/SyncUps/SyncUpDetail.swift +++ b/Examples/SyncUps/SyncUpDetail.swift @@ -7,7 +7,9 @@ import SwiftUINavigation final class SyncUpDetailModel: HashableObject { var destination: Destination? var isDismissed = false - @ObservationIgnored @Fetch var details: Details.Value + @ObservationIgnored @FetchAll var attendees: [Attendee] + @ObservationIgnored @FetchAll var meetings: [Meeting] + @ObservationIgnored @FetchOne var syncUp: SyncUp var onMeetingStarted: (SyncUp, [Attendee]) -> Void = unimplemented("onMeetingStarted") @@ -33,16 +35,15 @@ final class SyncUpDetailModel: HashableObject { syncUp: SyncUp ) { self.destination = destination - _details = Fetch( - wrappedValue: Details.Value(syncUp: syncUp), - Details(syncUp: syncUp), animation: .default - ) + _attendees = FetchAll(Attendee.where { $0.syncUpID.eq(syncUp.id) }) + _meetings = FetchAll(Meeting.where { $0.syncUpID.eq(syncUp.id) }) + _syncUp = FetchOne(wrappedValue: syncUp, SyncUp.find(syncUp.id)) } func deleteMeetings(atOffsets indices: IndexSet) { withErrorReporting { try database.write { db in - let ids = indices.map { details.meetings[$0].id } + let ids = indices.map { meetings[$0].id } try Meeting.where { ids.contains($0.id) }.delete().execute(db) } } @@ -58,13 +59,13 @@ final class SyncUpDetailModel: HashableObject { isDismissed = true try? await clock.sleep(for: .seconds(0.4)) await withErrorReporting { - try await database.write { [syncUp = details.syncUp] db in + try await database.write { [syncUp] db in try SyncUp.delete(syncUp).execute(db) } } case .continueWithoutRecording: - onMeetingStarted(details.syncUp, details.attendees) + onMeetingStarted(syncUp, attendees) case .openSettings: await openSettings() @@ -77,7 +78,7 @@ final class SyncUpDetailModel: HashableObject { func editButtonTapped() { destination = .edit( withDependencies(from: self) { - SyncUpFormModel(syncUp: SyncUp.Draft(details.syncUp)) + SyncUpFormModel(syncUp: SyncUp.Draft(syncUp)) } ) } @@ -85,7 +86,7 @@ final class SyncUpDetailModel: HashableObject { func startMeetingButtonTapped() { switch authorizationStatus() { case .notDetermined, .authorized: - onMeetingStarted(details.syncUp, details.attendees) + onMeetingStarted(syncUp, attendees) case .denied: destination = .alert(.speechRecognitionDenied) @@ -97,30 +98,6 @@ final class SyncUpDetailModel: HashableObject { break } } - - struct Details: FetchKeyRequest { - struct Value { - var attendees: [Attendee] = [] - var meetings: [Meeting] = [] - var syncUp: SyncUp - } - - let syncUp: SyncUp - - func fetch(_ db: Database) throws -> Value { - guard let syncUp = try SyncUp.where({ $0.id == syncUp.id }).fetchOne(db) - else { throw NotFound() } - return try Value( - attendees: Attendee.where { $0.syncUpID == syncUp.id }.fetchAll(db), - meetings: - Meeting - .where { $0.syncUpID.eq(syncUp.id) } - .order { $0.date.desc() } - .fetchAll(db), - syncUp: syncUp - ) - } - } } struct SyncUpDetailView: View { @@ -140,27 +117,27 @@ struct SyncUpDetailView: View { HStack { Label("Length", systemImage: "clock") Spacer() - Text(model.details.syncUp.seconds.duration.formatted(.units())) + Text(model.syncUp.seconds.duration.formatted(.units())) } HStack { Label("Theme", systemImage: "paintpalette") Spacer() - Text(model.details.syncUp.theme.name) + Text(model.syncUp.theme.name) .padding(4) - .foregroundColor(model.details.syncUp.theme.accentColor) - .background(model.details.syncUp.theme.mainColor) + .foregroundColor(model.syncUp.theme.accentColor) + .background(model.syncUp.theme.mainColor) .cornerRadius(4) } } header: { Text("Sync-up Info") } - if !model.details.meetings.isEmpty { + if !model.meetings.isEmpty { Section { - ForEach(model.details.meetings, id: \.id) { meeting in + ForEach(model.meetings, id: \.id) { meeting in NavigationLink( - value: AppModel.Path.meeting(meeting, attendees: model.details.attendees) + value: AppModel.Path.meeting(meeting, attendees: model.attendees) ) { HStack { Image(systemName: "calendar") @@ -178,7 +155,7 @@ struct SyncUpDetailView: View { } Section { - ForEach(model.details.attendees, id: \.id) { attendee in + ForEach(model.attendees, id: \.id) { attendee in Label(attendee.name, systemImage: "person") } } header: { @@ -193,7 +170,7 @@ struct SyncUpDetailView: View { .frame(maxWidth: .infinity) } } - .navigationTitle(model.details.syncUp.title) + .navigationTitle(model.syncUp.title) .toolbar { Button("Edit") { model.editButtonTapped() @@ -205,7 +182,7 @@ struct SyncUpDetailView: View { .sheet(item: $model.destination.edit) { editModel in NavigationStack { SyncUpFormView(model: editModel) - .navigationTitle(model.details.syncUp.title) + .navigationTitle(model.syncUp.title) } } .onChange(of: model.isDismissed) { @@ -294,18 +271,13 @@ struct MeetingView: View { } #Preview { - let _ = try! prepareDependencies { + let syncUp = try! prepareDependencies { $0.defaultDatabase = try SyncUps.appDatabase() - } - @Dependency(\.defaultDatabase) var database - let syncUp = try! database.read { db in - try SyncUp.limit(1).fetchOne(db)! + return try $0.defaultDatabase.read { db in + try SyncUp.limit(1).fetchOne(db)! + } } NavigationStack { - SyncUpDetailView( - model: SyncUpDetailModel( - syncUp: syncUp - ) - ) + SyncUpDetailView(model: SyncUpDetailModel(syncUp: syncUp)) } } diff --git a/Examples/SyncUps/SyncUpsList.swift b/Examples/SyncUps/SyncUpsList.swift index 4d9ff033..175deae6 100644 --- a/Examples/SyncUps/SyncUpsList.swift +++ b/Examples/SyncUps/SyncUpsList.swift @@ -1,6 +1,7 @@ import SharingGRDB import SwiftUI import SwiftUINavigation +import TipKit @MainActor @Observable @@ -11,10 +12,10 @@ final class SyncUpsListModel { SyncUp .group(by: \.id) .leftJoin(Attendee.all) { $0.id.eq($1.syncUpID) } - .select { Record.Columns(attendeeCount: $1.count(), syncUp: $0) }, + .select { Row.Columns(attendeeCount: $1.count(), syncUp: $0) }, animation: .default ) - var syncUps: [Record] + var syncUps: [Row] @ObservationIgnored @Dependency(\.uuid) var uuid @ObservationIgnored @Dependency(\.defaultDatabase) var database @@ -30,8 +31,18 @@ final class SyncUpsListModel { } } + #if DEBUG + func seedDatabase() { + withErrorReporting { + try database.write { db in + try db.seedSampleData() + } + } + } + #endif + @Selection - struct Record { + struct Row { let attendeeCount: Int let syncUp: SyncUp } @@ -39,6 +50,7 @@ final class SyncUpsListModel { struct SyncUpsList: View { @State var model = SyncUpsListModel() + @State private var seedDatabaseTip: SeedDatabaseTip? var body: some View { List { @@ -50,11 +62,37 @@ struct SyncUpsList: View { } } .toolbar { - Button { - model.addSyncUpButtonTapped() - } label: { - Image(systemName: "plus") + ToolbarItem(placement: .primaryAction) { + Button { + model.addSyncUpButtonTapped() + } label: { + Image(systemName: "plus") + } } +#if DEBUG + ToolbarItem(placement: .automatic) { + Menu { + Button { + model.seedDatabase() + } label: { + Text("Seed data") + Image(systemName: "leaf") + } + } label: { + Image(systemName: "ellipsis.circle") + } + .popoverTip(seedDatabaseTip) + .task { + await withErrorReporting { + try Tips.configure() + try await model.$syncUps.load() + if model.syncUps.isEmpty { + seedDatabaseTip = SeedDatabaseTip() + } + } + } + } +#endif } .navigationTitle("Daily Sync-ups") .sheet(item: $model.addSyncUp) { syncUpFormModel in @@ -101,6 +139,18 @@ extension LabelStyle where Self == TrailingIconLabelStyle { static var trailingIcon: Self { Self() } } +private struct SeedDatabaseTip: Tip { + var title: Text { + Text("Seed Sample Data") + } + var message: Text? { + Text("Tap here to quickly populate the app with test data.") + } + var image: Image? { + Image(systemName: "leaf") + } +} + #Preview { let _ = try! prepareDependencies { $0.defaultDatabase = try SyncUps.appDatabase() From 681657e41d6678af8154657da0dfde083bf57d3a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 31 May 2025 14:55:10 -0700 Subject: [PATCH 06/21] wip --- Examples/Reminders/ReminderForm.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index 9ac7cc5f..a5cc677c 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -4,7 +4,7 @@ import SwiftUI struct ReminderFormView: View { @FetchAll(RemindersList.order(by: \.title)) var remindersLists - @FetchOne var remindersList: RemindersList? + @FetchOne var remindersList: RemindersList @State var isPresentingTagsPopover = false @State var reminder: Reminder.Draft @@ -14,7 +14,7 @@ struct ReminderFormView: View { @Environment(\.dismiss) var dismiss init(existingReminder: Reminder? = nil, remindersList: RemindersList) { - _remindersList = FetchOne(RemindersList.find(remindersList.id)) + _remindersList = FetchOne(wrappedValue: remindersList, RemindersList.find(remindersList.id)) if let existingReminder { reminder = Reminder.Draft(existingReminder) } else { @@ -120,13 +120,13 @@ struct ReminderFormView: View { HStack { Image(systemName: "list.bullet.circle.fill") .font(.title) - .foregroundStyle(remindersList?.color ?? Color.blue) + .foregroundStyle(remindersList.color) Text("List") } } .task(id: reminder.remindersListID) { await withErrorReporting { - try await $remindersList.load(RemindersList?.find(reminder.remindersListID)) + try await $remindersList.load(RemindersList.find(reminder.remindersListID)) } } } From 03196d045d4bc71637b54b83e4b015203f7355ac Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 31 May 2025 16:16:04 -0700 Subject: [PATCH 07/21] wip --- Examples/Examples.xcodeproj/project.pbxproj | 8 + Examples/Reminders/ReminderForm.swift | 10 +- Examples/Reminders/ReminderRow.swift | 12 +- Examples/Reminders/RemindersApp.swift | 16 +- Examples/Reminders/RemindersDetail.swift | 326 ++++++++++---------- Examples/Reminders/RemindersListForm.swift | 20 +- Examples/Reminders/RemindersListRow.swift | 2 +- Examples/Reminders/RemindersLists.swift | 261 ++++++++++------ Examples/Reminders/Schema.swift | 23 +- Examples/Reminders/SearchReminders.swift | 185 ++++++----- 10 files changed, 488 insertions(+), 375 deletions(-) diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index d3c6a173..4b2653ea 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; + CA5E46912DEBB8570069E0F8 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */; }; CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CAD001862D874F1F00FA977A /* DependenciesTestSupport */; }; CAFDD64A2D5E823A00EE099E /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CAFDD6492D5E823A00EE099E /* SharingGRDB */; }; DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = DC5FA7472D4C63D60082743E /* DependenciesMacros */; }; @@ -137,6 +138,7 @@ files = ( CAFDD64A2D5E823A00EE099E /* SharingGRDB in Frameworks */, CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */, + CA5E46912DEBB8570069E0F8 /* SwiftUINavigation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -280,6 +282,7 @@ packageProductDependencies = ( CAFDD6492D5E823A00EE099E /* SharingGRDB */, CA14DBC82DA884C400E36852 /* CasePaths */, + CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */, ); productName = Reminders; productReference = CAF836D82D4735AB0047AEB5 /* Reminders.app */; @@ -912,6 +915,11 @@ package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = UIKitNavigation; }; + CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */ = { + isa = XCSwiftPackageProductDependency; + package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; + productName = SwiftUINavigation; + }; CAD001862D874F1F00FA977A /* DependenciesTestSupport */ = { isa = XCSwiftPackageProductDependency; package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index a5cc677c..c2f49c50 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -13,13 +13,9 @@ struct ReminderFormView: View { @Dependency(\.defaultDatabase) private var database @Environment(\.dismiss) var dismiss - init(existingReminder: Reminder? = nil, remindersList: RemindersList) { + init(reminder: Reminder.Draft, remindersList: RemindersList) { _remindersList = FetchOne(wrappedValue: remindersList, RemindersList.find(remindersList.id)) - if let existingReminder { - reminder = Reminder.Draft(existingReminder) - } else { - reminder = Reminder.Draft(remindersListID: remindersList.id) - } + self.reminder = reminder } var body: some View { @@ -218,7 +214,7 @@ struct ReminderFormPreview: PreviewProvider { } } NavigationStack { - ReminderFormView(existingReminder: reminder, remindersList: remindersList) + ReminderFormView(reminder: Reminder.Draft(reminder), remindersList: remindersList) .navigationTitle("Detail") } } diff --git a/Examples/Reminders/ReminderRow.swift b/Examples/Reminders/ReminderRow.swift index 479a420f..e2fdc9b7 100644 --- a/Examples/Reminders/ReminderRow.swift +++ b/Examples/Reminders/ReminderRow.swift @@ -10,7 +10,7 @@ struct ReminderRow: View { let showCompleted: Bool let tags: [String] - @State var editReminder: Reminder? + @State var editReminder: Reminder.Draft? @State var isCompleted: Bool @Dependency(\.defaultDatabase) private var database @@ -22,8 +22,7 @@ struct ReminderRow: View { reminder: Reminder, remindersList: RemindersList, showCompleted: Bool, - tags: [String], - editReminder: Reminder? = nil + tags: [String] ) { self.color = color self.isPastDue = isPastDue @@ -32,7 +31,6 @@ struct ReminderRow: View { self.remindersList = remindersList self.showCompleted = showCompleted self.tags = tags - self.editReminder = editReminder self.isCompleted = reminder.isCompleted } @@ -65,7 +63,7 @@ struct ReminderRow: View { .foregroundStyle(.orange) } Button { - editReminder = reminder + editReminder = Reminder.Draft(reminder) } label: { Image(systemName: "info.circle") } @@ -94,12 +92,12 @@ struct ReminderRow: View { } .tint(.orange) Button("Details") { - editReminder = reminder + editReminder = Reminder.Draft(reminder) } } .sheet(item: $editReminder) { reminder in NavigationStack { - ReminderFormView(existingReminder: reminder, remindersList: remindersList) + ReminderFormView(reminder: reminder, remindersList: remindersList) .navigationTitle("Details") } } diff --git a/Examples/Reminders/RemindersApp.swift b/Examples/Reminders/RemindersApp.swift index 4e0f9fd3..d5c55805 100644 --- a/Examples/Reminders/RemindersApp.swift +++ b/Examples/Reminders/RemindersApp.swift @@ -1,20 +1,16 @@ import SharingGRDB import SwiftUI -import TipKit @main struct RemindersApp: App { @Dependency(\.context) var context + static let model = RemindersListsModel() init() { - guard context == .live - else { return } - - try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() - } - withErrorReporting { - try Tips.configure() + if context == .live { + try! prepareDependencies { + $0.defaultDatabase = try Reminders.appDatabase() + } } } @@ -22,7 +18,7 @@ struct RemindersApp: App { WindowGroup { if context == .live { NavigationStack { - RemindersListsView() + RemindersListsView(model: Self.model) } } } diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 604517d8..7b13ae6c 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -1,132 +1,38 @@ import CasePaths import SharingGRDB import SwiftUI +import SwiftUINavigation -struct RemindersDetailView: View { - @FetchAll private var reminderStates: [ReminderState] - @AppStorage private var ordering: Ordering - @AppStorage private var showCompleted: Bool +@MainActor +@Observable +class RemindersDetailModel: HashableObject { + @ObservationIgnored @FetchAll var reminderStates: [Row] + @ObservationIgnored @Shared var ordering: Ordering + @ObservationIgnored @Shared var showCompleted: Bool let detailType: DetailType - @State var isNewReminderSheetPresented = false - @State var isNavigationTitleVisible = false - @State var navigationTitleHeight: CGFloat = 36 + var isNewReminderSheetPresented = false - @Dependency(\.defaultDatabase) private var database + @ObservationIgnored @Dependency(\.defaultDatabase) private var database init(detailType: DetailType) { self.detailType = detailType - _ordering = AppStorage(wrappedValue: .dueDate, "ordering_list_\(detailType.id)") - _showCompleted = AppStorage( + _ordering = Shared(wrappedValue: .dueDate, .appStorage("ordering_list_\(detailType.id)")) + _showCompleted = Shared( wrappedValue: detailType == .completed, - "show_completed_list_\(detailType.id)" + .appStorage("show_completed_list_\(detailType.id)") ) - _reminderStates = FetchAll(remindersQuery, animation: .default) + _reminderStates = FetchAll(remindersQuery) } - var body: some View { - List { - VStack(alignment: .leading) { - GeometryReader { proxy in - Text(detailType.navigationTitle) - .font(.system(.largeTitle, design: .rounded, weight: .bold)) - .foregroundStyle(detailType.color) - .onAppear { navigationTitleHeight = proxy.size.height } - } - } - .listRowSeparator(.hidden) - ForEach(reminderStates) { reminderState in - ReminderRow( - color: detailType.color, - isPastDue: reminderState.isPastDue, - notes: reminderState.notes, - reminder: reminderState.reminder, - remindersList: reminderState.remindersList, - showCompleted: showCompleted, - tags: reminderState.tags - ) - } - .onMove { indexSet, index in - move(from: indexSet, to: index) - } - } - .onScrollGeometryChange(for: Bool.self) { geometry in - geometry.contentOffset.y + geometry.contentInsets.top > navigationTitleHeight - } action: { - isNavigationTitleVisible = $1 - } - .listStyle(.plain) - .sheet(isPresented: $isNewReminderSheetPresented) { - if let remindersList = detailType.list { - NavigationStack { - ReminderFormView(remindersList: remindersList) - .navigationTitle("New Reminder") - } - } - } - .task(id: [ordering, showCompleted] as [AnyHashable]) { - await withErrorReporting { - try await updateQuery() - } - } - .toolbar { - ToolbarItem(placement: .principal) { - Text(detailType.navigationTitle) - .font(.headline) - .opacity(isNavigationTitleVisible ? 1 : 0) - .animation(.default.speed(2), value: isNavigationTitleVisible) - } - } - .toolbarTitleDisplayMode(.inline) - .toolbar { - if detailType.is(\.list) { - ToolbarItem(placement: .bottomBar) { - HStack { - Button { - isNewReminderSheetPresented = true - } label: { - HStack { - Image(systemName: "plus.circle.fill") - Text("New Reminder") - } - .bold() - .font(.title3) - } - Spacer() - } - .tint(detailType.color) - } - } - ToolbarItem(placement: .primaryAction) { - Menu { - Group { - Menu { - ForEach(Ordering.allCases, id: \.self) { ordering in - Button { - self.ordering = ordering - } label: { - Text(ordering.rawValue) - ordering.icon - } - } - } label: { - Text("Sort By") - Text(ordering.rawValue) - Image(systemName: "arrow.up.arrow.down") - } - Button { - showCompleted.toggle() - } label: { - Text(showCompleted ? "Hide Completed" : "Show Completed") - Image(systemName: showCompleted ? "eye.slash.fill" : "eye") - } - } - .tint(detailType.color) - } label: { - Image(systemName: "ellipsis.circle") - } - } - } + func orderingButtonTapped(_ ordering: Ordering) async { + $ordering.withLock { $0 = ordering } + await updateQuery() + } + + func showCompletedButtonTapped() async { + $showCompleted.withLock { $0.toggle() } + await updateQuery() } func move(from source: IndexSet, to destination: Int) { @@ -149,41 +55,16 @@ struct RemindersDetailView: View { .execute(db) } } - ordering = .manual + $ordering.withLock { $0 = .manual } } - - private enum Ordering: String, CaseIterable { - case dueDate = "Due Date" - case manual = "Manual" - case priority = "Priority" - case title = "Title" - var icon: Image { - switch self { - case .dueDate: Image(systemName: "calendar") - case .manual: Image(systemName: "hand.draw") - case .priority: Image(systemName: "chart.bar.fill") - case .title: Image(systemName: "textformat.characters") - } + + private func updateQuery() async { + await withErrorReporting { + try await $reminderStates.load(remindersQuery, animation: .default) } } - @CasePathable - @dynamicMemberLookup - enum DetailType: Hashable { - case all - case completed - case flagged - case list(RemindersList) - case scheduled - case tags([Tag]) - case today - } - - private func updateQuery() async throws { - try await $reminderStates.load(remindersQuery) - } - - fileprivate var remindersQuery: some StructuredQueriesCore.Statement { + private var remindersQuery: some StructuredQueriesCore.Statement { let query = Reminder .where { @@ -206,7 +87,7 @@ struct RemindersDetailView: View { case .all: !reminder.isCompleted case .completed: reminder.isCompleted case .flagged: reminder.isFlagged - case .list(let list): reminder.remindersListID.eq(list.id) + case .remindersList(let list): reminder.remindersListID.eq(list.id) case .scheduled: reminder.isScheduled case .tags(let tags): tag.id.ifnull(UUID(0)).in(tags.map(\.id)) case .today: reminder.isToday @@ -214,7 +95,7 @@ struct RemindersDetailView: View { } .join(RemindersList.all) { $0.remindersListID.eq($3.id) } .select { - ReminderState.Columns( + Row.Columns( reminder: $0, remindersList: $3, isPastDue: $0.isPastDue, @@ -225,8 +106,35 @@ struct RemindersDetailView: View { return query } + enum Ordering: String, CaseIterable { + case dueDate = "Due Date" + case manual = "Manual" + case priority = "Priority" + case title = "Title" + var icon: Image { + switch self { + case .dueDate: Image(systemName: "calendar") + case .manual: Image(systemName: "hand.draw") + case .priority: Image(systemName: "chart.bar.fill") + case .title: Image(systemName: "textformat.characters") + } + } + } + + @CasePathable + @dynamicMemberLookup + enum DetailType: Hashable { + case all + case completed + case flagged + case remindersList(RemindersList) + case scheduled + case tags([Tag]) + case today + } + @Selection - fileprivate struct ReminderState: Identifiable { + struct Row: Identifiable { var id: Reminder.ID { reminder.id } let reminder: Reminder let remindersList: RemindersList @@ -237,13 +145,119 @@ struct RemindersDetailView: View { } } -extension RemindersDetailView.DetailType { +struct RemindersDetailView: View { + @Bindable var model: RemindersDetailModel + + @State var isNavigationTitleVisible = false + @State var navigationTitleHeight: CGFloat = 36 + + var body: some View { + List { + VStack(alignment: .leading) { + GeometryReader { proxy in + Text(model.detailType.navigationTitle) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + .foregroundStyle(model.detailType.color) + .onAppear { navigationTitleHeight = proxy.size.height } + } + } + .listRowSeparator(.hidden) + ForEach(model.reminderStates) { reminderState in + ReminderRow( + color: model.detailType.color, + isPastDue: reminderState.isPastDue, + notes: reminderState.notes, + reminder: reminderState.reminder, + remindersList: reminderState.remindersList, + showCompleted: model.showCompleted, + tags: reminderState.tags + ) + } + .onMove(perform: model.move(from:to:)) + } + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.contentOffset.y + geometry.contentInsets.top > navigationTitleHeight + } action: { + isNavigationTitleVisible = $1 + } + .listStyle(.plain) + .sheet(isPresented: $model.isNewReminderSheetPresented) { + if let remindersList = model.detailType.remindersList { + NavigationStack { + ReminderFormView( + reminder: Reminder.Draft(remindersListID: remindersList.id), + remindersList: remindersList + ) + .navigationTitle("New Reminder") + } + } + } + .toolbar { + ToolbarItem(placement: .principal) { + Text(model.detailType.navigationTitle) + .font(.headline) + .opacity(isNavigationTitleVisible ? 1 : 0) + .animation(.default.speed(2), value: isNavigationTitleVisible) + } + if model.detailType.is(\.remindersList) { + ToolbarItem(placement: .bottomBar) { + HStack { + Button { + model.isNewReminderSheetPresented = true + } label: { + HStack { + Image(systemName: "plus.circle.fill") + Text("New Reminder") + } + .bold() + .font(.title3) + } + Spacer() + } + .tint(model.detailType.color) + } + } + ToolbarItem(placement: .primaryAction) { + Menu { + Group { + Menu { + ForEach(RemindersDetailModel.Ordering.allCases, id: \.self) { ordering in + Button { + Task { await model.orderingButtonTapped(ordering) } + } label: { + Text(ordering.rawValue) + ordering.icon + } + } + } label: { + Text("Sort By") + Text(model.ordering.rawValue) + Image(systemName: "arrow.up.arrow.down") + } + Button { + Task { await model.showCompletedButtonTapped() } + } label: { + Text(model.showCompleted ? "Hide Completed" : "Show Completed") + Image(systemName: model.showCompleted ? "eye.slash.fill" : "eye") + } + } + .tint(model.detailType.color) + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .toolbarTitleDisplayMode(.inline) + } +} + +extension RemindersDetailModel.DetailType { fileprivate var id: String { switch self { case .all: "all" case .completed: "completed" case .flagged: "flagged" - case .list(let list): "list_\(list.id)" + case .remindersList(let list): "list_\(list.id)" case .scheduled: "scheduled" case .tags: "tags" case .today: "today" @@ -254,7 +268,7 @@ extension RemindersDetailView.DetailType { case .all: "All" case .completed: "Completed" case .flagged: "Flagged" - case .list(let list): list.title + case .remindersList(let list): list.title case .scheduled: "Scheduled" case .tags(let tags): switch tags.count { @@ -270,7 +284,7 @@ extension RemindersDetailView.DetailType { case .all: .black case .completed: .gray case .flagged: .orange - case .list(let list): list.color + case .remindersList(let list): list.color case .scheduled: .red case .tags: .blue case .today: .blue @@ -289,14 +303,14 @@ struct RemindersDetailPreview: PreviewProvider { ) } } - let detailTypes: [RemindersDetailView.DetailType] = [ + let detailTypes: [RemindersDetailModel.DetailType] = [ .all, - .list(remindersList), + .remindersList(remindersList), .tags([tag]), ] ForEach(detailTypes, id: \.self) { detailType in NavigationStack { - RemindersDetailView(detailType: detailType) + RemindersDetailView(model: RemindersDetailModel(detailType: detailType)) } .previewDisplayName(detailType.navigationTitle) } diff --git a/Examples/Reminders/RemindersListForm.swift b/Examples/Reminders/RemindersListForm.swift index 37bb2acc..e62fbb12 100644 --- a/Examples/Reminders/RemindersListForm.swift +++ b/Examples/Reminders/RemindersListForm.swift @@ -8,8 +8,8 @@ struct RemindersListForm: View { @State var remindersList: RemindersList.Draft @Environment(\.dismiss) var dismiss - init(existingList: RemindersList.Draft? = nil) { - remindersList = existingList ?? RemindersList.Draft() + init(remindersList: RemindersList.Draft) { + self.remindersList = remindersList } var body: some View { @@ -50,12 +50,14 @@ struct RemindersListForm: View { } } -#Preview { - let _ = try! prepareDependencies { - $0.defaultDatabase = try Reminders.appDatabase() - } - NavigationStack { - RemindersListForm() - .navigationTitle("New List") +struct RemindersListFormPreviews: PreviewProvider { + static var previews: some View { + let _ = try! prepareDependencies { + $0.defaultDatabase = try Reminders.appDatabase() + } + NavigationStack { + RemindersListForm(remindersList: RemindersList.Draft()) + .navigationTitle("New List") + } } } diff --git a/Examples/Reminders/RemindersListRow.swift b/Examples/Reminders/RemindersListRow.swift index 493b22f0..9efeeff2 100644 --- a/Examples/Reminders/RemindersListRow.swift +++ b/Examples/Reminders/RemindersListRow.swift @@ -42,7 +42,7 @@ struct RemindersListRow: View { } .sheet(item: $editList) { list in NavigationStack { - RemindersListForm(existingList: RemindersList.Draft(list)) + RemindersListForm(remindersList: RemindersList.Draft(list)) .navigationTitle("Edit list") } .presentationDetents([.medium]) diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index d78be861..c9889ad5 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -1,8 +1,12 @@ import SharingGRDB import SwiftUI +import SwiftUINavigation import TipKit -struct RemindersListsView: View { +@MainActor +@Observable +class RemindersListsModel { + @ObservationIgnored @FetchAll( RemindersList .group(by: \.id) @@ -13,8 +17,9 @@ struct RemindersListsView: View { }, animation: .default ) - private var remindersLists + var remindersLists + @ObservationIgnored @FetchAll( Tag .order(by: \.title) @@ -23,8 +28,9 @@ struct RemindersListsView: View { .select { tag, _, _ in tag }, animation: .default ) - private var tags + var tags + @ObservationIgnored @FetchOne( Reminder.select { Stats.Columns( @@ -35,76 +41,175 @@ struct RemindersListsView: View { ) } ) - private var stats = Stats() + var stats = Stats() - @State private var destination: Destination? - @State private var remindersDetailType: RemindersDetailView.DetailType? - @State private var searchText = "" - @State private var seedDatabaseTip: SeedDatabaseTip? + var destination: Destination? + var searchRemindersModel = SearchRemindersModel() + var seedDatabaseTip: SeedDatabaseTip? + @ObservationIgnored @Dependency(\.defaultDatabase) private var database + func statTapped(_ detailType: RemindersDetailModel.DetailType) { + destination = .detail(RemindersDetailModel(detailType: detailType)) + } + + func remindersListTapped(remindersList: RemindersList) { + destination = .detail( + RemindersDetailModel( + detailType: .remindersList( + remindersList + ) + ) + ) + } + + func tagButtonTapped(tag: Tag) { + destination = .detail( + RemindersDetailModel( + detailType: .tags([tag]) + ) + ) + } + + func onAppear() { + withErrorReporting { + try Tips.configure() + } + if remindersLists.isEmpty { + seedDatabaseTip = SeedDatabaseTip() + } + } + + func newReminderButtonTapped() { + guard let remindersList = remindersLists.first?.remindersList + else { + reportIssue("There must be at least one list.") + return + } + destination = .reminderForm( + Reminder.Draft(remindersListID: remindersList.id), + remindersList: remindersList + ) + } + + func addListButtonTapped() { + destination = .remindersListForm(RemindersList.Draft()) + } + + func listDetailsButtonTapped(remindersList: RemindersList) { + destination = .remindersListForm(RemindersList.Draft(remindersList)) + } + + func move(from source: IndexSet, to destination: Int) { + withErrorReporting { + try database.write { db in + var ids = remindersLists.map(\.remindersList.id) + ids.move(fromOffsets: source, toOffset: destination) + try RemindersList + .where { $0.id.in(ids) } + .update { + let ids = Array(ids.enumerated()) + let (first, rest) = (ids.first!, ids.dropFirst()) + $0.position = + rest + .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in + cases.when(id.element, then: id.offset) + } + .else($0.position) + } + .execute(db) + } + } + } + + #if DEBUG + func seedDatabaseButtonTapped() { + withErrorReporting { + try database.write { db in + try db.seedSampleData() + } + } + } + #endif + + @CasePathable + enum Destination { + case detail(RemindersDetailModel) + case reminderForm(Reminder.Draft, remindersList: RemindersList) + case remindersListForm(RemindersList.Draft) + } + @Selection - fileprivate struct ReminderListState: Identifiable { + struct ReminderListState: Identifiable { var id: RemindersList.ID { remindersList.id } var remindersCount: Int var remindersList: RemindersList } @Selection - fileprivate struct Stats { + struct Stats { var allCount = 0 var flaggedCount = 0 var scheduledCount = 0 var todayCount = 0 } - enum Destination: Int, Identifiable { - case addList - case newReminder - - var id: Int { rawValue } + struct SeedDatabaseTip: Tip { + var title: Text { + Text("Seed Sample Data") + } + var message: Text? { + Text("Tap here to quickly populate the app with test data.") + } + var image: Image? { + Image(systemName: "leaf") + } } +} + +struct RemindersListsView: View { + @Bindable var model: RemindersListsModel var body: some View { List { - if searchText.isEmpty { + if model.searchRemindersModel.searchText.isEmpty { Section { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 16) { GridRow { ReminderGridCell( color: .blue, - count: stats.todayCount, + count: model.stats.todayCount, iconName: "calendar.circle.fill", title: "Today" ) { - remindersDetailType = .today + model.statTapped(.today) } ReminderGridCell( color: .red, - count: stats.scheduledCount, + count: model.stats.scheduledCount, iconName: "calendar.circle.fill", title: "Scheduled" ) { - remindersDetailType = .scheduled + model.statTapped(.scheduled) } } GridRow { ReminderGridCell( color: .gray, - count: stats.allCount, + count: model.stats.allCount, iconName: "tray.circle.fill", title: "All" ) { - remindersDetailType = .all + model.statTapped(.all) } ReminderGridCell( color: .orange, - count: stats.flaggedCount, + count: model.stats.flaggedCount, iconName: "flag.circle.fill", title: "Flagged" ) { - remindersDetailType = .flagged + model.statTapped(.flagged) } } GridRow { @@ -114,7 +219,7 @@ struct RemindersListsView: View { iconName: "checkmark.circle.fill", title: "Completed" ) { - remindersDetailType = .completed + model.statTapped(.completed) } } } @@ -124,19 +229,18 @@ struct RemindersListsView: View { } Section { - ForEach(remindersLists) { state in - NavigationLink { - RemindersDetailView(detailType: .list(state.remindersList)) + ForEach(model.remindersLists) { state in + Button { + model.remindersListTapped(remindersList: state.remindersList) } label: { RemindersListRow( remindersCount: state.remindersCount, remindersList: state.remindersList ) } + .foregroundStyle(.primary) } - .onMove { indexSet, index in - move(from: indexSet, to: index) - } + .onMove(perform: model.move(from:to:)) } header: { Text("My Lists") .font(.system(.title2, design: .rounded, weight: .bold)) @@ -148,12 +252,13 @@ struct RemindersListsView: View { .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) Section { - ForEach(tags) { tag in - NavigationLink { - RemindersDetailView(detailType: .tags([tag])) + ForEach(model.tags) { tag in + Button { + model.tagButtonTapped(tag: tag) } label: { TagRow(tag: tag) } + .foregroundStyle(.primary) } } header: { Text("Tags") @@ -165,27 +270,19 @@ struct RemindersListsView: View { } .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) } else { - SearchRemindersView(searchText: searchText) + SearchRemindersView(model: model.searchRemindersModel) } } - .task { - if remindersLists.isEmpty { - seedDatabaseTip = SeedDatabaseTip() - } + .onAppear { + model.onAppear() } - // NB: This explicit view identity works around a bug with 'List' view state not getting reset. - .id(searchText) .listStyle(.insetGrouped) .toolbar { #if DEBUG ToolbarItem(placement: .automatic) { Menu { Button { - withErrorReporting { - try database.write { db in - try db.seedSampleData() - } - } + model.seedDatabaseButtonTapped() } label: { Text("Seed data") Image(systemName: "leaf") @@ -193,13 +290,13 @@ struct RemindersListsView: View { } label: { Image(systemName: "ellipsis.circle") } - .popoverTip(seedDatabaseTip) + .popoverTip(model.seedDatabaseTip) } #endif ToolbarItem(placement: .bottomBar) { HStack { Button { - destination = .newReminder + model.newReminderButtonTapped() } label: { HStack { Image(systemName: "plus.circle.fill") @@ -210,7 +307,7 @@ struct RemindersListsView: View { } Spacer() Button { - destination = .addList + model.addListButtonTapped() } label: { Text("Add List") .font(.title3) @@ -218,48 +315,22 @@ struct RemindersListsView: View { } } } - .sheet(item: $destination) { destination in - switch destination { - case .addList: - NavigationStack { - RemindersListForm() - .navigationTitle("New List") - } - .presentationDetents([.medium]) - case .newReminder: - if let remindersList = remindersLists.first?.remindersList { - NavigationStack { - ReminderFormView(remindersList: remindersList) - .navigationTitle("New Reminder") - } - } + .sheet(item: $model.destination.reminderForm, id: \.0.id) { reminder, remindersList in + NavigationStack { + ReminderFormView(reminder: reminder, remindersList: remindersList) + .navigationTitle("New Reminder") } } - .searchable(text: $searchText) - .navigationDestination(item: $remindersDetailType) { detailType in - RemindersDetailView(detailType: detailType) - } - } - - func move(from source: IndexSet, to destination: Int) { - withErrorReporting { - try database.write { db in - var ids = remindersLists.map(\.remindersList.id) - ids.move(fromOffsets: source, toOffset: destination) - try RemindersList - .where { $0.id.in(ids) } - .update { - let ids = Array(ids.enumerated()) - let (first, rest) = (ids.first!, ids.dropFirst()) - $0.position = - rest - .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in - cases.when(id.element, then: id.offset) - } - .else($0.position) - } - .execute(db) + .sheet(item: $model.destination.remindersListForm) { remindersList in + NavigationStack { + RemindersListForm(remindersList: remindersList) + .navigationTitle("New List") } + .presentationDetents([.medium]) + } + .searchable(text: $model.searchRemindersModel.searchText) + .navigationDestination(item: $model.destination.detail) { detailModel in + RemindersDetailView(model: detailModel) } } } @@ -304,23 +375,11 @@ private struct ReminderGridCell: View { } } -private struct SeedDatabaseTip: Tip { - var title: Text { - Text("Seed Sample Data") - } - var message: Text? { - Text("Tap here to quickly populate the app with test data.") - } - var image: Image? { - Image(systemName: "leaf") - } -} - #Preview { let _ = try! prepareDependencies { $0.defaultDatabase = try Reminders.appDatabase() } NavigationStack { - RemindersListsView() + RemindersListsView(model: RemindersListsModel()) } } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index bb34f473..e7e7ba7f 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -13,6 +13,8 @@ struct RemindersList: Hashable, Identifiable { var title = "" } +extension RemindersList.Draft: Identifiable {} + @Table struct Reminder: Codable, Equatable, Identifiable { let id: UUID @@ -26,6 +28,8 @@ struct Reminder: Codable, Equatable, Identifiable { var title = "" } +extension Reminder.Draft: Identifiable {} + @Table struct Tag: Hashable, Identifiable { let id: UUID @@ -146,7 +150,7 @@ func appDatabase() throws -> any DatabaseWriter { """ CREATE TABLE "tags" ( "id" TEXT UNIQUE NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), - "title" TEXT NOT NULL COLLATE NOCASE UNIQUE + "title" TEXT NOT NULL COLLATE NOCASE ) STRICT """ ) @@ -198,6 +202,19 @@ func appDatabase() throws -> any DatabaseWriter { """ ) .execute(db) + try #sql( + """ + CREATE TEMPORARY TRIGGER "non_empty_reminders_lists" + AFTER DELETE ON "remindersLists" + FOR EACH ROW BEGIN + INSERT INTO "remindersLists" + ("title", "color") + SELECT 'Personal', \(raw: 0x4a99ef) + WHERE (SELECT count(*) FROM "remindersLists") = 0; + END + """ + ) + .execute(db) } return database @@ -326,6 +343,10 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") ReminderTag(reminderID: reminderIDs[2], tagID: tagIDs[6]) ReminderTag(reminderID: reminderIDs[3], tagID: tagIDs[0]) ReminderTag(reminderID: reminderIDs[3], tagID: tagIDs[1]) + ReminderTag(reminderID: reminderIDs[4], tagID: tagIDs[4]) + ReminderTag(reminderID: reminderIDs[3], tagID: tagIDs[4]) + ReminderTag(reminderID: reminderIDs[10], tagID: tagIDs[4]) + ReminderTag(reminderID: reminderIDs[4], tagID: tagIDs[5]) } } } diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index a67cd53b..b0ee752f 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -2,93 +2,27 @@ import IssueReporting import SharingGRDB import SwiftUI -struct SearchRemindersView: View { - @State @FetchOne var completedCount: Int = 0 - @State @FetchAll var reminders: [ReminderState] - - let searchText: String - @State var showCompletedInSearchResults = false - - @Dependency(\.defaultDatabase) private var database - - init(searchText: String) { - self.searchText = searchText - _reminders = State(wrappedValue: FetchAll()) +@MainActor +@Observable +class SearchRemindersModel { + var showCompletedInSearchResults = false + var searchText = "" { + didSet { + Task { await updateQuery() } + } } - var body: some View { - HStack { - Text("\(completedCount) Completed") - .monospacedDigit() - .contentTransition(.numericText()) - if completedCount > 0 { - Text("•") - Menu { - Text("Clear Completed Reminders") - Button("Older Than 1 Month") { deleteCompletedReminders(monthsAgo: 1) } - Button("Older Than 6 Months") { deleteCompletedReminders(monthsAgo: 6) } - Button("Older Than 1 year") { deleteCompletedReminders(monthsAgo: 12) } - Button("All Completed") { deleteCompletedReminders() } - } label: { - Text("Clear") - } - Spacer() - Button(showCompletedInSearchResults ? "Hide" : "Show") { - showCompletedInSearchResults.toggle() - } - } - } - .buttonStyle(.borderless) - .task(id: [searchText, showCompletedInSearchResults] as [AnyHashable]) { - await withErrorReporting { - try await updateSearchQuery() - } - } + @ObservationIgnored @FetchOne var completedCount: Int = 0 + @ObservationIgnored @FetchAll var reminders: [Row] - ForEach(reminders) { reminder in - ReminderRow( - color: reminder.remindersList.color, - isPastDue: reminder.isPastDue, - notes: reminder.notes, - reminder: reminder.reminder, - remindersList: reminder.remindersList, - showCompleted: showCompletedInSearchResults, - tags: reminder.tags - ) - } - } + @ObservationIgnored @Dependency(\.defaultDatabase) private var database - private func updateSearchQuery() async throws { - if searchText.isEmpty { - showCompletedInSearchResults = false - } - try await $completedCount.wrappedValue.load( - Reminder.searching(searchText) - .where(\.isCompleted) - .count(), - animation: .default - ) - try await $reminders.wrappedValue.load( - Reminder - .searching(searchText) - .where { showCompletedInSearchResults || !$0.isCompleted } - .order { ($0.isCompleted, $0.dueDate) } - .withTags - .join(RemindersList.all) { $0.remindersListID.eq($3.id) } - .select { - ReminderState.Columns( - isPastDue: $0.isPastDue, - notes: $0.inlineNotes, - reminder: $0, - remindersList: $3, - tags: #sql("\($2.jsonNames)") - ) - }, - animation: .default - ) + func showCompletedButtonTapped() async { + showCompletedInSearchResults.toggle() + await updateQuery() } - private func deleteCompletedReminders(monthsAgo: Int? = nil) { + func deleteCompletedReminders(monthsAgo: Int? = nil) { withErrorReporting { try database.write { db in try Reminder @@ -105,8 +39,40 @@ struct SearchRemindersView: View { } } + private func updateQuery() async { + await withErrorReporting { + if searchText.isEmpty { + showCompletedInSearchResults = false + } + try await $completedCount.load( + Reminder.searching(searchText) + .where(\.isCompleted) + .count(), + animation: .default + ) + try await $reminders.load( + Reminder + .searching(searchText) + .where { showCompletedInSearchResults || !$0.isCompleted } + .order { ($0.isCompleted, $0.dueDate) } + .withTags + .join(RemindersList.all) { $0.remindersListID.eq($3.id) } + .select { + Row.Columns( + isPastDue: $0.isPastDue, + notes: $0.inlineNotes, + reminder: $0, + remindersList: $3, + tags: #sql("\($2.jsonNames)") + ) + }, + animation: .default + ) + } + } + @Selection - struct ReminderState: Identifiable { + struct Row: Identifiable { var id: Reminder.ID { reminder.id } let isPastDue: Bool let notes: String @@ -117,6 +83,59 @@ struct SearchRemindersView: View { } } +struct SearchRemindersView: View { + let model: SearchRemindersModel + + init(model: SearchRemindersModel) { + self.model = model + } + + var body: some View { + HStack { + Text("\(model.completedCount) Completed") + .monospacedDigit() + .contentTransition(.numericText()) + if model.completedCount > 0 { + Text("•") + Menu { + Text("Clear Completed Reminders") + Button("Older Than 1 Month") { + model.deleteCompletedReminders(monthsAgo: 1) + } + Button("Older Than 6 Months") { + model.deleteCompletedReminders(monthsAgo: 6) + } + Button("Older Than 1 year") { + model.deleteCompletedReminders(monthsAgo: 12) + } + Button("All Completed") { + model.deleteCompletedReminders() + } + } label: { + Text("Clear") + } + Spacer() + Button(model.showCompletedInSearchResults ? "Hide" : "Show") { + Task { await model.showCompletedButtonTapped() } + } + } + } + .buttonStyle(.borderless) + + ForEach(model.reminders) { reminder in + ReminderRow( + color: reminder.remindersList.color, + isPastDue: reminder.isPastDue, + notes: reminder.notes, + reminder: reminder.reminder, + remindersList: reminder.remindersList, + showCompleted: model.showCompletedInSearchResults, + tags: reminder.tags + ) + } + } +} + #Preview { @Previewable @State var searchText = "take" let _ = try! prepareDependencies { @@ -126,7 +145,7 @@ struct SearchRemindersView: View { NavigationStack { List { if !searchText.isEmpty { - SearchRemindersView(searchText: searchText) + SearchRemindersView(model: SearchRemindersModel()) } else { Text(#"Tap "Search"..."#) } From 2651226fdbd2973bc718c602e045bb8adcf29171 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 31 May 2025 19:49:12 -0700 Subject: [PATCH 08/21] wip --- .github/workflows/ci.yml | 15 ++++++++ Examples/Reminders/RemindersDetail.swift | 2 +- Makefile | 44 ++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 Makefile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60a85271..29bd8dca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,21 @@ jobs: - name: Run ${{ matrix.config }} tests run: swift test -c ${{ matrix.config }} + examples: + name: Examples + strategy: + matrix: + xcode: ['16.3'] + config: ['debug'] + schemes: ['Reminders', 'CaseStudies', 'SyncUps'] + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: Build ${{ matrix.scheme }} + run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="${{ matrix.scheme }}" xcodebuild-raw + linux: name: Linux strategy: diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 7b13ae6c..5da9b391 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -75,7 +75,7 @@ class RemindersDetailModel: HashableObject { .order { $0.isCompleted } .order { switch ordering { - case .dueDate: $0.dueDate + case .dueDate: $0.dueDate.asc(nulls: .last) case .manual: $0.position case .priority: ($0.priority.desc(), $0.isFlagged.desc()) case .title: $0.title diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..05cacda2 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +CONFIG = Debug + +PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iPhone) +PLATFORM = IOS +DESTINATION = platform="$(PLATFORM_$(PLATFORM))" +PLATFORM_ID = $(shell echo "$(DESTINATION)" | sed -E "s/.+,id=(.+)/\1/") +WORKSPACE = SharingGRDB.xcworkspace +XCODEBUILD_ARGUMENT = test +XCODEBUILD_FLAGS = \ + -configuration $(CONFIG) \ + -derivedDataPath $(DERIVED_DATA_PATH) \ + -destination $(DESTINATION) \ + -scheme "$(SCHEME)" \ + -skipMacroValidation \ + -workspace $(WORKSPACE) +XCODEBUILD_COMMAND = xcodebuild $(XCODEBUILD_ARGUMENT) $(XCODEBUILD_FLAGS) + +# TODO: Prefer 'xcbeautify --quiet' when this is fixed: +# https://github.com/cpisciotta/xcbeautify/issues/339 +ifneq ($(strip $(shell which xcbeautify)),) + XCODEBUILD = set -o pipefail && $(XCODEBUILD_COMMAND) | xcbeautify +else + XCODEBUILD = $(XCODEBUILD_COMMAND) +endif + +TEST_RUNNER_CI = $(CI) + +warm-simulator: + @test "$(PLATFORM_ID)" != "" \ + && xcrun simctl boot $(PLATFORM_ID) \ + && open -a Simulator --args -CurrentDeviceUDID $(PLATFORM_ID) \ + || exit 0 + +xcodebuild: warm-simulator + $(XCODEBUILD) + +xcodebuild-raw: warm-simulator + $(XCODEBUILD_COMMAND) + +.PHONY: warm-simulator xcodebuild xcodebuild-raw + +define udid_for +$(shell xcrun simctl list --json devices available '$(1)' | jq -r '[.devices|to_entries|sort_by(.key)|reverse|.[].value|select(length > 0)|.[0]][0].udid') +endef From eab8c762c57ced2abeeb238f4a73962e0b711c1b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 31 May 2025 19:51:34 -0700 Subject: [PATCH 09/21] wip --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29bd8dca..54620e03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: matrix: xcode: ['16.3'] config: ['debug'] - schemes: ['Reminders', 'CaseStudies', 'SyncUps'] + scheme: ['Reminders', 'CaseStudies', 'SyncUps'] runs-on: macos-15 steps: - uses: actions/checkout@v4 From c8fa7016396550b7e9ef8f24be2d39a0c19d26db Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 31 May 2025 20:15:31 -0700 Subject: [PATCH 10/21] fix --- Makefile | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index 05cacda2..f45f403e 100644 --- a/Makefile +++ b/Makefile @@ -1,41 +1,48 @@ CONFIG = Debug +DERIVED_DATA_PATH = ~/.derivedData/$(CONFIG) + PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iPhone) PLATFORM = IOS DESTINATION = platform="$(PLATFORM_$(PLATFORM))" + PLATFORM_ID = $(shell echo "$(DESTINATION)" | sed -E "s/.+,id=(.+)/\1/") + WORKSPACE = SharingGRDB.xcworkspace + XCODEBUILD_ARGUMENT = test + XCODEBUILD_FLAGS = \ - -configuration $(CONFIG) \ - -derivedDataPath $(DERIVED_DATA_PATH) \ - -destination $(DESTINATION) \ - -scheme "$(SCHEME)" \ - -skipMacroValidation \ - -workspace $(WORKSPACE) + -configuration $(CONFIG) \ + -derivedDataPath $(DERIVED_DATA_PATH) \ + -destination $(DESTINATION) \ + -scheme "$(SCHEME)" \ + -skipMacroValidation \ + -workspace $(WORKSPACE) + XCODEBUILD_COMMAND = xcodebuild $(XCODEBUILD_ARGUMENT) $(XCODEBUILD_FLAGS) # TODO: Prefer 'xcbeautify --quiet' when this is fixed: # https://github.com/cpisciotta/xcbeautify/issues/339 ifneq ($(strip $(shell which xcbeautify)),) - XCODEBUILD = set -o pipefail && $(XCODEBUILD_COMMAND) | xcbeautify + XCODEBUILD = set -o pipefail && $(XCODEBUILD_COMMAND) | xcbeautify else - XCODEBUILD = $(XCODEBUILD_COMMAND) + XCODEBUILD = $(XCODEBUILD_COMMAND) endif TEST_RUNNER_CI = $(CI) warm-simulator: - @test "$(PLATFORM_ID)" != "" \ - && xcrun simctl boot $(PLATFORM_ID) \ - && open -a Simulator --args -CurrentDeviceUDID $(PLATFORM_ID) \ - || exit 0 + @test "$(PLATFORM_ID)" != "" \ + && xcrun simctl boot $(PLATFORM_ID) \ + && open -a Simulator --args -CurrentDeviceUDID $(PLATFORM_ID) \ + || exit 0 xcodebuild: warm-simulator - $(XCODEBUILD) + $(XCODEBUILD) xcodebuild-raw: warm-simulator - $(XCODEBUILD_COMMAND) + $(XCODEBUILD_COMMAND) .PHONY: warm-simulator xcodebuild xcodebuild-raw From 8f85c8c2e477dce0f07623635de25f97e24736b2 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 31 May 2025 20:22:42 -0700 Subject: [PATCH 11/21] wip --- Examples/Examples.xcodeproj/project.pbxproj | 118 +++++++++++++++++- .../xcshareddata/xcschemes/Reminders.xcscheme | 2 +- Examples/RemindersTests/RemindersTests.swift | 6 + Examples/SyncUpTests/SyncUpFormTests.swift | 14 +-- 4 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 Examples/RemindersTests/RemindersTests.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 4b2653ea..0ca88270 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -20,6 +20,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + CA5E469A2DEBFE410069E0F8 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = CAF836902D4735620047AEB5 /* Project object */; + proxyType = 1; + remoteGlobalIDString = CAF836D72D4735AB0047AEB5; + remoteInfo = Reminders; + }; CAD001812D874E6F00FA977A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = CAF836902D4735620047AEB5 /* Project object */; @@ -37,6 +44,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + CA5E46962DEBFE410069E0F8 /* RemindersTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RemindersTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CA5F37542D5AFBBC002E1A9E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SyncUpTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CAF836982D4735620047AEB5 /* CaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -71,6 +79,11 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + CA5E46972DEBFE410069E0F8 /* RemindersTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = RemindersTests; + sourceTree = ""; + }; CAD0017E2D874E6F00FA977A /* SyncUpTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = SyncUpTests; @@ -108,6 +121,13 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + CA5E46932DEBFE410069E0F8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CAD0017A2D874E6F00FA977A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -163,6 +183,7 @@ CAF8369A2D4735620047AEB5 /* CaseStudies */, CAF836AB2D4735640047AEB5 /* CaseStudiesTests */, CAF836D92D4735AB0047AEB5 /* Reminders */, + CA5E46972DEBFE410069E0F8 /* RemindersTests */, DCBE89CD2D483FB90071F499 /* SyncUps */, CAD0017E2D874E6F00FA977A /* SyncUpTests */, CAF837022D4735C00047AEB5 /* Frameworks */, @@ -178,6 +199,7 @@ CAF836D82D4735AB0047AEB5 /* Reminders.app */, DCBE89CC2D483FB90071F499 /* SyncUps.app */, CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */, + CA5E46962DEBFE410069E0F8 /* RemindersTests.xctest */, ); name = Products; sourceTree = ""; @@ -192,6 +214,29 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + CA5E46952DEBFE410069E0F8 /* RemindersTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CA5E469C2DEBFE420069E0F8 /* Build configuration list for PBXNativeTarget "RemindersTests" */; + buildPhases = ( + CA5E46922DEBFE410069E0F8 /* Sources */, + CA5E46932DEBFE410069E0F8 /* Frameworks */, + CA5E46942DEBFE410069E0F8 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + CA5E469B2DEBFE410069E0F8 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + CA5E46972DEBFE410069E0F8 /* RemindersTests */, + ); + name = RemindersTests; + packageProductDependencies = ( + ); + productName = RemindersTests; + productReference = CA5E46962DEBFE410069E0F8 /* RemindersTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; CAD0017C2D874E6F00FA977A /* SyncUpTests */ = { isa = PBXNativeTarget; buildConfigurationList = CAD001852D874E6F00FA977A /* Build configuration list for PBXNativeTarget "SyncUpTests" */; @@ -321,9 +366,13 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1630; + LastSwiftUpdateCheck = 1640; LastUpgradeCheck = 1620; TargetAttributes = { + CA5E46952DEBFE410069E0F8 = { + CreatedOnToolsVersion = 16.4; + TestTargetID = CAF836D72D4735AB0047AEB5; + }; CAD0017C2D874E6F00FA977A = { CreatedOnToolsVersion = 16.3; TestTargetID = DCBE89CB2D483FB90071F499; @@ -365,6 +414,7 @@ CAF836972D4735620047AEB5 /* CaseStudies */, CAF836A72D4735640047AEB5 /* CaseStudiesTests */, CAF836D72D4735AB0047AEB5 /* Reminders */, + CA5E46952DEBFE410069E0F8 /* RemindersTests */, DCBE89CB2D483FB90071F499 /* SyncUps */, CAD0017C2D874E6F00FA977A /* SyncUpTests */, ); @@ -372,6 +422,13 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + CA5E46942DEBFE410069E0F8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CAD0017B2D874E6F00FA977A /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -410,6 +467,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + CA5E46922DEBFE410069E0F8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; CAD001792D874E6F00FA977A /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -448,6 +512,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + CA5E469B2DEBFE410069E0F8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CAF836D72D4735AB0047AEB5 /* Reminders */; + targetProxy = CA5E469A2DEBFE410069E0F8 /* PBXContainerItemProxy */; + }; CAD001822D874E6F00FA977A /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DCBE89CB2D483FB90071F499 /* SyncUps */; @@ -461,6 +530,40 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + CA5E469D2DEBFE420069E0F8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.RemindersTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Reminders.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Reminders"; + }; + name = Debug; + }; + CA5E469E2DEBFE420069E0F8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.RemindersTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Reminders.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Reminders"; + }; + name = Release; + }; CAD001832D874E6F00FA977A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -468,7 +571,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SyncUpTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -486,7 +588,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SyncUpTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -681,7 +782,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CaseStudiesTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -698,7 +798,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.CaseStudiesTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -821,6 +920,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + CA5E469C2DEBFE420069E0F8 /* Build configuration list for PBXNativeTarget "RemindersTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CA5E469D2DEBFE420069E0F8 /* Debug */, + CA5E469E2DEBFE420069E0F8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; CAD001852D874E6F00FA977A /* Build configuration list for PBXNativeTarget "SyncUpTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/Reminders.xcscheme b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/Reminders.xcscheme index ae069ea8..6e71fe38 100644 --- a/Examples/Examples.xcodeproj/xcshareddata/xcschemes/Reminders.xcscheme +++ b/Examples/Examples.xcodeproj/xcshareddata/xcschemes/Reminders.xcscheme @@ -35,7 +35,7 @@ parallelizable = "YES"> diff --git a/Examples/RemindersTests/RemindersTests.swift b/Examples/RemindersTests/RemindersTests.swift new file mode 100644 index 00000000..93c623fb --- /dev/null +++ b/Examples/RemindersTests/RemindersTests.swift @@ -0,0 +1,6 @@ +import Testing + +struct RemindersTests { + @Test func example() async throws { + } +} diff --git a/Examples/SyncUpTests/SyncUpFormTests.swift b/Examples/SyncUpTests/SyncUpFormTests.swift index 0bba708a..0273a6e8 100644 --- a/Examples/SyncUpTests/SyncUpFormTests.swift +++ b/Examples/SyncUpTests/SyncUpFormTests.swift @@ -62,23 +62,23 @@ struct SyncUpFormTests { extension Database { fileprivate func seedSyncUpFormTests() throws { try seed { - SyncUp(id: 1, seconds: 60, theme: .appOrange, title: "Design") - SyncUp(id: 2, seconds: 60 * 10, theme: .periwinkle, title: "Engineering") - SyncUp(id: 3, seconds: 60 * 30, theme: .poppy, title: "Product") + SyncUp(id: UUID(1), seconds: 60, theme: .appOrange, title: "Design") + SyncUp(id: UUID(2), seconds: 60 * 10, theme: .periwinkle, title: "Engineering") + SyncUp(id: UUID(3), seconds: 60 * 30, theme: .poppy, title: "Product") for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { - Attendee.Draft(name: name, syncUpID: 1) + Attendee.Draft(name: name, syncUpID: UUID(1)) } for name in ["Blob", "Blob Jr"] { - Attendee.Draft(name: name, syncUpID: 2) + Attendee.Draft(name: name, syncUpID: UUID(2)) } for name in ["Blob Sr", "Blob Jr"] { - Attendee.Draft(name: name, syncUpID: 3) + Attendee.Draft(name: name, syncUpID: UUID(3)) } Meeting.Draft( date: Date().addingTimeInterval(-60 * 60 * 24 * 7), - syncUpID: 1, + syncUpID: UUID(1), transcript: """ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ From 0c0285b29bdde67342922d2b370894ce72a47040 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 31 May 2025 20:29:06 -0700 Subject: [PATCH 12/21] wip --- .github/workflows/ci.yml | 2 +- Package.resolved | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54620e03..b870e04a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode ${{ matrix.xcode }} run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - - name: Build ${{ matrix.scheme }} + - name: xcodebuild ${{ matrix.scheme }} run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="${{ matrix.scheme }}" xcodebuild-raw linux: diff --git a/Package.resolved b/Package.resolved index b4b90afd..ad64da7e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6af43fe820ccae2bb136d800d909d9dbc414ddf4d36d59981ccea0db4061c24a", + "originHash" : "f5261f0540baa2624ed52becc0bb4246ad0ee522376f5a8c691f1857097815d9", "pins" : [ { "identity" : "combine-schedulers", @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "1be8144023c367c5de701a6313ed29a3a10bf59b", - "version" : "1.18.3" + "revision" : "37230a37e83f1b7023be08e1b1a2603fcb1567fb", + "version" : "1.18.4" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "71657e2f1d5b5af29e8cc5c450a67523433671b1", - "version" : "0.2.0" + "revision" : "f4af59124b43d3f971ac1a26f8b1428406b5ae35", + "version" : "0.5.0" } }, { @@ -136,6 +136,15 @@ "version" : "600.0.1" } }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", From eaf3244b135a4f975259dfca29ed5fd74d456969 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 1 Jun 2025 14:47:18 -0700 Subject: [PATCH 13/21] lots of tests --- Examples/Examples.xcodeproj/project.pbxproj | 33 ++ Examples/Reminders/RemindersDetail.swift | 27 +- Examples/Reminders/Schema.swift | 27 +- Examples/RemindersTests/Internal.swift | 156 +++++++ .../RemindersDetailsTests.swift | 389 ++++++++++++++++++ .../RemindersTests/RemindersListsTests.swift | 155 +++++++ Examples/RemindersTests/RemindersTests.swift | 6 - .../RemindersTests/SearchRemindersTests.swift | 208 ++++++++++ Examples/SyncUpTests/Internal.swift | 37 ++ Examples/SyncUpTests/SyncUpFormTests.swift | 33 -- .../xcshareddata/swiftpm/Package.resolved | 10 +- 11 files changed, 1018 insertions(+), 63 deletions(-) create mode 100644 Examples/RemindersTests/Internal.swift create mode 100644 Examples/RemindersTests/RemindersDetailsTests.swift create mode 100644 Examples/RemindersTests/RemindersListsTests.swift delete mode 100644 Examples/RemindersTests/RemindersTests.swift create mode 100644 Examples/RemindersTests/SearchRemindersTests.swift create mode 100644 Examples/SyncUpTests/Internal.swift diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 0ca88270..f4e780af 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -10,6 +10,9 @@ CA14DBC92DA884C400E36852 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = CA14DBC82DA884C400E36852 /* CasePaths */; }; CA2908C92D4AF70E003F165F /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA2908C82D4AF70E003F165F /* UIKitNavigation */; }; CA5E46912DEBB8570069E0F8 /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E46902DEBB8570069E0F8 /* SwiftUINavigation */; }; + CA5E47072DECEF0F0069E0F8 /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E47062DECEF0F0069E0F8 /* InlineSnapshotTesting */; }; + CA5E47092DECEFC80069E0F8 /* SnapshotTestingCustomDump in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E47082DECEFC80069E0F8 /* SnapshotTestingCustomDump */; }; + CA5E470B2DECF0280069E0F8 /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CA5E470A2DECF0280069E0F8 /* DependenciesTestSupport */; }; CAD001872D874F1F00FA977A /* DependenciesTestSupport in Frameworks */ = {isa = PBXBuildFile; productRef = CAD001862D874F1F00FA977A /* DependenciesTestSupport */; }; CAFDD64A2D5E823A00EE099E /* SharingGRDB in Frameworks */ = {isa = PBXBuildFile; productRef = CAFDD6492D5E823A00EE099E /* SharingGRDB */; }; DC5FA7482D4C63D60082743E /* DependenciesMacros in Frameworks */ = {isa = PBXBuildFile; productRef = DC5FA7472D4C63D60082743E /* DependenciesMacros */; }; @@ -125,6 +128,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CA5E47092DECEFC80069E0F8 /* SnapshotTestingCustomDump in Frameworks */, + CA5E47072DECEF0F0069E0F8 /* InlineSnapshotTesting in Frameworks */, + CA5E470B2DECF0280069E0F8 /* DependenciesTestSupport in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -232,6 +238,9 @@ ); name = RemindersTests; packageProductDependencies = ( + CA5E47062DECEF0F0069E0F8 /* InlineSnapshotTesting */, + CA5E47082DECEFC80069E0F8 /* SnapshotTestingCustomDump */, + CA5E470A2DECF0280069E0F8 /* DependenciesTestSupport */, ); productName = RemindersTests; productReference = CA5E46962DEBFE410069E0F8 /* RemindersTests.xctest */; @@ -405,6 +414,7 @@ DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */, DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */, DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */, + CA5E47052DECEF0F0069E0F8 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, ); preferredProjectObjectVersion = 77; productRefGroup = CAF836992D4735620047AEB5 /* Products */; @@ -986,6 +996,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + CA5E47052DECEF0F0069E0F8 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-snapshot-testing.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.18.4; + }; + }; DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-dependencies"; @@ -1028,6 +1046,21 @@ package = DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */; productName = SwiftUINavigation; }; + CA5E47062DECEF0F0069E0F8 /* InlineSnapshotTesting */ = { + isa = XCSwiftPackageProductDependency; + package = CA5E47052DECEF0F0069E0F8 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; + productName = InlineSnapshotTesting; + }; + CA5E47082DECEFC80069E0F8 /* SnapshotTestingCustomDump */ = { + isa = XCSwiftPackageProductDependency; + package = CA5E47052DECEF0F0069E0F8 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; + productName = SnapshotTestingCustomDump; + }; + CA5E470A2DECF0280069E0F8 /* DependenciesTestSupport */ = { + isa = XCSwiftPackageProductDependency; + package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; + productName = DependenciesTestSupport; + }; CAD001862D874F1F00FA977A /* DependenciesTestSupport */ = { isa = XCSwiftPackageProductDependency; package = DC5FA7462D4C63D60082743E /* XCRemoteSwiftPackageReference "swift-dependencies" */; diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 5da9b391..189810c7 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -6,7 +6,7 @@ import SwiftUINavigation @MainActor @Observable class RemindersDetailModel: HashableObject { - @ObservationIgnored @FetchAll var reminderStates: [Row] + @ObservationIgnored @FetchAll var reminderRows: [Row] @ObservationIgnored @Shared var ordering: Ordering @ObservationIgnored @Shared var showCompleted: Bool @@ -22,7 +22,7 @@ class RemindersDetailModel: HashableObject { wrappedValue: detailType == .completed, .appStorage("show_completed_list_\(detailType.id)") ) - _reminderStates = FetchAll(remindersQuery) + _reminderRows = FetchAll(remindersQuery) } func orderingButtonTapped(_ ordering: Ordering) async { @@ -35,10 +35,10 @@ class RemindersDetailModel: HashableObject { await updateQuery() } - func move(from source: IndexSet, to destination: Int) { + func move(from source: IndexSet, to destination: Int) async { withErrorReporting { try database.write { db in - var ids = reminderStates.map(\.reminder.id) + var ids = reminderRows.map(\.reminder.id) ids.move(fromOffsets: source, toOffset: destination) try Reminder .where { $0.id.in(ids) } @@ -56,11 +56,12 @@ class RemindersDetailModel: HashableObject { } } $ordering.withLock { $0 = .manual } + await updateQuery() } private func updateQuery() async { await withErrorReporting { - try await $reminderStates.load(remindersQuery, animation: .default) + try await $reminderRows.load(remindersQuery, animation: .default) } } @@ -162,18 +163,20 @@ struct RemindersDetailView: View { } } .listRowSeparator(.hidden) - ForEach(model.reminderStates) { reminderState in + ForEach(model.reminderRows) { row in ReminderRow( color: model.detailType.color, - isPastDue: reminderState.isPastDue, - notes: reminderState.notes, - reminder: reminderState.reminder, - remindersList: reminderState.remindersList, + isPastDue: row.isPastDue, + notes: row.notes, + reminder: row.reminder, + remindersList: row.remindersList, showCompleted: model.showCompleted, - tags: reminderState.tags + tags: row.tags ) } - .onMove(perform: model.move(from:to:)) + .onMove { source, destination in + Task { await model.move(from: source, to: destination) } + } } .onScrollGeometryChange(for: Bool.self) { geometry in geometry.contentOffset.y + geometry.contentInsets.top > navigationTitleHeight diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index e7e7ba7f..5c525a6b 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -22,9 +22,9 @@ struct Reminder: Codable, Equatable, Identifiable { var isCompleted = false var isFlagged = false var notes = "" + var position = 0 var priority: Priority? var remindersListID: RemindersList.ID - var position = 0 var title = "" } @@ -57,10 +57,10 @@ extension Reminder { extension Reminder.TableColumns { var isPastDue: some QueryExpression { - !isCompleted && #sql("coalesce(date(\(dueDate)) < date('now'), 0)") + !isCompleted && #sql("coalesce(date(\(dueDate)) < now(), 0)") } var isToday: some QueryExpression { - !isCompleted && #sql("coalesce(date(\(dueDate)) = date('now'), 0)") + !isCompleted && #sql("coalesce(date(\(dueDate)) = now(), 0)") } var isScheduled: some QueryExpression { !isCompleted && dueDate.isNot(nil) @@ -104,13 +104,26 @@ func appDatabase() throws -> any DatabaseWriter { } } #endif + db.add( + function: DatabaseFunction("now", argumentCount: 0) { _ in + @Dependency(\.date.now) var now + return now.formatted( + .iso8601.year().month().day() + .dateTimeSeparator(.space) + .time(includingFractionalSeconds: true) + ) + } + ) } - if context == .live { - let path = URL.documentsDirectory.appending(component: "db.sqlite").path() + if context == .preview { + database = try DatabaseQueue(configuration: configuration) + } else { + let path = + context == .live + ? URL.documentsDirectory.appending(component: "db.sqlite").path() + : URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() logger.info("open \(path)") database = try DatabasePool(path: path, configuration: configuration) - } else { - database = try DatabaseQueue(configuration: configuration) } var migrator = DatabaseMigrator() #if DEBUG diff --git a/Examples/RemindersTests/Internal.swift b/Examples/RemindersTests/Internal.swift new file mode 100644 index 00000000..8923b380 --- /dev/null +++ b/Examples/RemindersTests/Internal.swift @@ -0,0 +1,156 @@ +import Foundation +import SharingGRDB +import SwiftUI +import Testing + +@testable import Reminders + +@Suite( + .dependencies { + $0.date.now = Date(timeIntervalSince1970: 1234567890) + $0.defaultDatabase = try Reminders.appDatabase() + try $0.defaultDatabase.write { try $0.seedTestData() } + }, + .snapshots(record: .failed) +) +struct BaseTestSuite {} + +extension Database { + func seedTestData() throws { + let baseDate = Date(timeIntervalSince1970: 1234567890) + try seed { + RemindersList( + id: UUID(0), + color: Color(red: 0x4a / 255, green: 0x99 / 255, blue: 0xef / 255), + position: 0, + title: "Personal" + ) + RemindersList( + id: UUID(1), + color: Color(red: 0xed / 255, green: 0x89 / 255, blue: 0x35 / 255), + position: 1, + title: "Family" + ) + RemindersList( + id: UUID(2), + color: Color(red: 0xb2 / 255, green: 0x5d / 255, blue: 0xd3 / 255), + position: 2, + title: "Business" + ) + Reminder( + id: UUID(0), + notes: "Milk\nEggs\nApples\nOatmeal\nSpinach", + position: 0, + remindersListID: UUID(0), + title: "Groceries" + ) + Reminder( + id: UUID(1), + dueDate: baseDate.addingTimeInterval(-60 * 60 * 24 * 2), + isFlagged: true, + position: 1, + remindersListID: UUID(0), + title: "Haircut" + ) + Reminder( + id: UUID(2), + dueDate: baseDate, + notes: "Ask about diet", + position: 2, + priority: .high, + remindersListID: UUID(0), + title: "Doctor appointment" + ) + Reminder( + id: UUID(3), + dueDate: baseDate.addingTimeInterval(-60 * 60 * 24 * 190), + isCompleted: true, + position: 3, + remindersListID: UUID(0), + title: "Take a walk" + ) + Reminder( + id: UUID(4), + dueDate: baseDate, + position: 4, + remindersListID: UUID(0), + title: "Buy concert tickets" + ) + Reminder( + id: UUID(5), + dueDate: baseDate.addingTimeInterval(60 * 60 * 24 * 2), + isFlagged: true, + position: 5, + priority: .high, + remindersListID: UUID(1), + title: "Pick up kids from school" + ) + Reminder( + id: UUID(6), + dueDate: baseDate.addingTimeInterval(-60 * 60 * 24 * 2), + isCompleted: true, + position: 6, + priority: .low, + remindersListID: UUID(1), + title: "Get laundry" + ) + Reminder( + id: UUID(7), + dueDate: baseDate.addingTimeInterval(60 * 60 * 24 * 4), + isCompleted: false, + position: 7, + priority: .high, + remindersListID: UUID(1), + title: "Take out trash" + ) + Reminder( + id: UUID(8), + dueDate: baseDate.addingTimeInterval(60 * 60 * 24 * 2), + notes: """ + Status of tax return + Expenses for next year + Changing payroll company + """, + position: 8, + remindersListID: UUID(2), + title: "Call accountant" + ) + Reminder( + id: UUID(9), + dueDate: baseDate.addingTimeInterval(-60 * 60 * 24 * 2), + isCompleted: true, + position: 9, + priority: .medium, + remindersListID: UUID(2), + title: "Send weekly emails" + ) + Reminder( + id: UUID(10), + dueDate: baseDate.addingTimeInterval(60 * 60 * 24 * 2), + isCompleted: false, + position: 10, + remindersListID: UUID(2), + title: "Prepare for WWDC" + ) + Tag(id: UUID(0), title: "car") + Tag(id: UUID(1), title: "kids") + Tag(id: UUID(2), title: "someday") + Tag(id: UUID(3), title: "optional") + Tag(id: UUID(4), title: "social") + Tag(id: UUID(5), title: "night") + Tag(id: UUID(6), title: "adulting") + ReminderTag(reminderID: UUID(0), tagID: UUID(2)) + ReminderTag(reminderID: UUID(0), tagID: UUID(3)) + ReminderTag(reminderID: UUID(0), tagID: UUID(6)) + ReminderTag(reminderID: UUID(1), tagID: UUID(2)) + ReminderTag(reminderID: UUID(1), tagID: UUID(3)) + ReminderTag(reminderID: UUID(2), tagID: UUID(6)) + ReminderTag(reminderID: UUID(3), tagID: UUID(0)) + ReminderTag(reminderID: UUID(3), tagID: UUID(1)) + ReminderTag(reminderID: UUID(4), tagID: UUID(4)) + ReminderTag(reminderID: UUID(3), tagID: UUID(4)) + ReminderTag(reminderID: UUID(10), tagID: UUID(4)) + ReminderTag(reminderID: UUID(4), tagID: UUID(5)) + } + } +} diff --git a/Examples/RemindersTests/RemindersDetailsTests.swift b/Examples/RemindersTests/RemindersDetailsTests.swift new file mode 100644 index 00000000..b5f4c03c --- /dev/null +++ b/Examples/RemindersTests/RemindersDetailsTests.swift @@ -0,0 +1,389 @@ +import Dependencies +import DependenciesTestSupport +import InlineSnapshotTesting +import SnapshotTestingCustomDump +import Testing + +@testable import Reminders + +extension BaseTestSuite { + @MainActor + struct RemindersDetailsTests { + @Dependency(\.defaultDatabase) var database + + @Test func basics() async throws { + let remindersList = try await database.read { try RemindersList.all.fetchOne($0)! } + let model = RemindersDetailModel(detailType: .remindersList(remindersList)) + try await model.$reminderRows.load() + assertInlineSnapshot(of: model.reminderRows, as: .customDump) { + #""" + [ + [0]: RemindersDetailModel.Row( + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-000000000001), + dueDate: Date(2009-02-11T23:31:30.000Z), + isCompleted: false, + isFlagged: true, + notes: "", + position: 2, + priority: nil, + remindersListID: UUID(00000000-0000-0000-0000-000000000000), + title: "Haircut" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000000), + color: Color( + provider: ColorBox( + base: ResolvedColorProvider( + color: Color.Resolved( + linearRed: 0.06662594, + linearGreen: 0.31854683, + linearBlue: 0.8631573, + opacity: 1.0 + ) + ) + ) + ), + position: 1, + title: "Personal" + ), + isPastDue: true, + notes: "", + tags: [ + [0]: "someday", + [1]: "optional" + ] + ), + [1]: RemindersDetailModel.Row( + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-000000000002), + dueDate: Date(2009-02-13T23:31:30.000Z), + isCompleted: false, + isFlagged: false, + notes: "Ask about diet", + position: 3, + priority: .high, + remindersListID: UUID(00000000-0000-0000-0000-000000000000), + title: "Doctor appointment" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000000), + color: Color( + provider: #1 ColorBox( + base: ResolvedColorProvider( + color: Color.Resolved( + linearRed: 0.06662594, + linearGreen: 0.31854683, + linearBlue: 0.8631573, + opacity: 1.0 + ) + ) + ) + ), + position: 1, + title: "Personal" + ), + isPastDue: true, + notes: "Ask about diet", + tags: [ + [0]: "adulting" + ] + ), + [2]: RemindersDetailModel.Row( + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-000000000004), + dueDate: Date(2009-02-13T23:31:30.000Z), + isCompleted: false, + isFlagged: false, + notes: "", + position: 5, + priority: nil, + remindersListID: UUID(00000000-0000-0000-0000-000000000000), + title: "Buy concert tickets" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000000), + color: Color( + provider: #2 ColorBox( + base: ResolvedColorProvider( + color: Color.Resolved( + linearRed: 0.06662594, + linearGreen: 0.31854683, + linearBlue: 0.8631573, + opacity: 1.0 + ) + ) + ) + ), + position: 1, + title: "Personal" + ), + isPastDue: true, + notes: "", + tags: [ + [0]: "social", + [1]: "night" + ] + ), + [3]: RemindersDetailModel.Row( + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-000000000000), + dueDate: nil, + isCompleted: false, + isFlagged: false, + notes: """ + Milk + Eggs + Apples + Oatmeal + Spinach + """, + position: 1, + priority: nil, + remindersListID: UUID(00000000-0000-0000-0000-000000000000), + title: "Groceries" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000000), + color: Color( + provider: #3 ColorBox( + base: ResolvedColorProvider( + color: Color.Resolved( + linearRed: 0.06662594, + linearGreen: 0.31854683, + linearBlue: 0.8631573, + opacity: 1.0 + ) + ) + ) + ), + position: 1, + title: "Personal" + ), + isPastDue: false, + notes: "Milk Eggs Apples Oatmeal Spinach", + tags: [ + [0]: "someday", + [1]: "optional", + [2]: "adulting" + ] + ) + ] + """# + } + } + + @Test func ordering() async throws { + let remindersList = try await database.read { try RemindersList.all.fetchOne($0)! } + let model = RemindersDetailModel(detailType: .remindersList(remindersList)) + + try await model.$reminderRows.load() + #expect(model.ordering == .dueDate) + assertInlineSnapshot(of: model.reminderRows.map(\.reminder.title), as: .customDump) { + """ + [ + [0]: "Haircut", + [1]: "Doctor appointment", + [2]: "Buy concert tickets", + [3]: "Groceries" + ] + """ + } + + await model.orderingButtonTapped(.priority) + try await model.$reminderRows.load() + #expect(model.ordering == .priority) + assertInlineSnapshot(of: model.reminderRows.map(\.reminder.title), as: .customDump) { + """ + [ + [0]: "Doctor appointment", + [1]: "Haircut", + [2]: "Groceries", + [3]: "Buy concert tickets" + ] + """ + } + + await model.orderingButtonTapped(.title) + try await model.$reminderRows.load() + #expect(model.ordering == .title) + assertInlineSnapshot(of: model.reminderRows.map(\.reminder.title), as: .customDump) { + """ + [ + [0]: "Buy concert tickets", + [1]: "Doctor appointment", + [2]: "Groceries", + [3]: "Haircut" + ] + """ + } + } + + @Test func showCompleted() async throws { + let remindersList = try await database.read { try RemindersList.all.fetchOne($0)! } + let model = RemindersDetailModel(detailType: .remindersList(remindersList)) + + try await model.$reminderRows.load() + #expect(model.showCompleted == false) + assertInlineSnapshot(of: model.reminderRows.map(\.reminder.title), as: .customDump) { + """ + [ + [0]: "Haircut", + [1]: "Doctor appointment", + [2]: "Buy concert tickets", + [3]: "Groceries" + ] + """ + } + + await model.showCompletedButtonTapped() + try await model.$reminderRows.load() + #expect(model.showCompleted == true) + assertInlineSnapshot(of: model.reminderRows.map(\.reminder.title), as: .customDump) { + """ + [ + [0]: "Haircut", + [1]: "Doctor appointment", + [2]: "Buy concert tickets", + [3]: "Groceries", + [4]: "Take a walk" + ] + """ + } + + await model.showCompletedButtonTapped() + try await model.$reminderRows.load() + #expect(model.showCompleted == false) + assertInlineSnapshot(of: model.reminderRows.map(\.reminder.title), as: .customDump) { + """ + [ + [0]: "Haircut", + [1]: "Doctor appointment", + [2]: "Buy concert tickets", + [3]: "Groceries" + ] + """ + } + } + + @Test func move() async throws { + let remindersList = try await database.read { try RemindersList.all.fetchOne($0)! } + let model = RemindersDetailModel(detailType: .remindersList(remindersList)) + + try await model.$reminderRows.load() + assertInlineSnapshot(of: model.reminderRows.map(\.reminder.title), as: .customDump) { + """ + [ + [0]: "Haircut", + [1]: "Doctor appointment", + [2]: "Buy concert tickets", + [3]: "Groceries" + ] + """ + } + + await model.move(from: [2], to: 0) + try await model.$reminderRows.load() + assertInlineSnapshot(of: model.reminderRows.map(\.reminder.title), as: .customDump) { + """ + [ + [0]: "Buy concert tickets", + [1]: "Haircut", + [2]: "Doctor appointment", + [3]: "Groceries" + ] + """ + } + #expect(model.ordering == .manual) + } + + @Test func all() async throws { + let model = RemindersDetailModel(detailType: .all) + try await model.$reminderRows.load() + assertInlineSnapshot(of: model.reminderRows.map(\.reminder.title), as: .customDump) { + """ + [ + [0]: "Haircut", + [1]: "Doctor appointment", + [2]: "Buy concert tickets", + [3]: "Pick up kids from school", + [4]: "Call accountant", + [5]: "Prepare for WWDC", + [6]: "Take out trash", + [7]: "Groceries" + ] + """ + } + } + + @Test func completed() async throws { + let model = RemindersDetailModel(detailType: .completed) + try await model.$reminderRows.load() + assertInlineSnapshot(of: model.reminderRows.map(\.reminder.title), as: .customDump) { + """ + [ + [0]: "Take a walk", + [1]: "Get laundry", + [2]: "Send weekly emails" + ] + """ + } + } + + @Test func flagged() async throws { + let model = RemindersDetailModel(detailType: .flagged) + try await model.$reminderRows.load() + assertInlineSnapshot(of: model.reminderRows.map(\.reminder.title), as: .customDump) { + """ + [ + [0]: "Haircut", + [1]: "Pick up kids from school" + ] + """ + } + } + + @Test func scheduled() async throws { + let model = RemindersDetailModel(detailType: .scheduled) + try await model.$reminderRows.load() + assertInlineSnapshot(of: model.reminderRows.map(\.reminder.title), as: .customDump) { + """ + [ + [0]: "Haircut", + [1]: "Doctor appointment", + [2]: "Buy concert tickets", + [3]: "Pick up kids from school", + [4]: "Call accountant", + [5]: "Prepare for WWDC", + [6]: "Take out trash" + ] + """ + } + } + + @Test func today() async throws { + let model = RemindersDetailModel(detailType: .today) + try await model.$reminderRows.load() + assertInlineSnapshot(of: model.reminderRows.map(\.reminder.title), as: .customDump) { + """ + [] + """ + } + } + + @Test func tagged() async throws { + let tag = try await database.read { try Tag.all.fetchOne($0)! } + let model = RemindersDetailModel(detailType: .tags([tag])) + try await model.$reminderRows.load() + assertInlineSnapshot(of: model.reminderRows.map(\.reminder.title), as: .customDump) { + """ + [ + [0]: "Pick up kids from school", + [1]: "Call accountant", + [2]: "Take out trash" + ] + """ + } + } + } +} diff --git a/Examples/RemindersTests/RemindersListsTests.swift b/Examples/RemindersTests/RemindersListsTests.swift new file mode 100644 index 00000000..b6ee9931 --- /dev/null +++ b/Examples/RemindersTests/RemindersListsTests.swift @@ -0,0 +1,155 @@ +import DependenciesTestSupport +import InlineSnapshotTesting +import SnapshotTestingCustomDump +import Testing + +@testable import Reminders + +extension BaseTestSuite { + @MainActor + struct RemindersListsTests { + @Test func basics() async throws { + let model = RemindersListsModel() + try await model.$remindersLists.load() + try await model.$stats.load() + try await model.$tags.load() + + assertInlineSnapshot(of: model.remindersLists, as: .customDump) { + """ + [ + [0]: RemindersListsModel.ReminderListState( + remindersCount: 4, + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000000), + color: Color( + provider: ColorBox( + base: ResolvedColorProvider( + color: Color.Resolved( + linearRed: 0.06662594, + linearGreen: 0.31854683, + linearBlue: 0.8631573, + opacity: 1.0 + ) + ) + ) + ), + position: 1, + title: "Personal" + ) + ), + [1]: RemindersListsModel.ReminderListState( + remindersCount: 2, + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000001), + color: Color( + provider: #1 ColorBox( + base: ResolvedColorProvider( + color: Color.Resolved( + linearRed: 0.8468733, + linearGreen: 0.25015837, + linearBlue: 0.0343398, + opacity: 1.0 + ) + ) + ) + ), + position: 2, + title: "Family" + ) + ), + [2]: RemindersListsModel.ReminderListState( + remindersCount: 2, + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000002), + color: Color( + provider: #2 ColorBox( + base: ResolvedColorProvider( + color: Color.Resolved( + linearRed: 0.44520125, + linearGreen: 0.10946172, + linearBlue: 0.6514057, + opacity: 1.0 + ) + ) + ) + ), + position: 3, + title: "Business" + ) + ) + ] + """ + } + assertInlineSnapshot(of: model.stats, as: .customDump) { + """ + RemindersListsModel.Stats( + allCount: 8, + flaggedCount: 2, + scheduledCount: 7, + todayCount: 0 + ) + """ + } + assertInlineSnapshot(of: model.tags, as: .customDump) { + """ + [ + [0]: Tag( + id: UUID(00000000-0000-0000-0000-000000000006), + title: "adulting" + ), + [1]: Tag( + id: UUID(00000000-0000-0000-0000-000000000000), + title: "car" + ), + [2]: Tag( + id: UUID(00000000-0000-0000-0000-000000000001), + title: "kids" + ), + [3]: Tag( + id: UUID(00000000-0000-0000-0000-000000000005), + title: "night" + ), + [4]: Tag( + id: UUID(00000000-0000-0000-0000-000000000003), + title: "optional" + ), + [5]: Tag( + id: UUID(00000000-0000-0000-0000-000000000004), + title: "social" + ), + [6]: Tag( + id: UUID(00000000-0000-0000-0000-000000000002), + title: "someday" + ) + ] + """ + } + } + + @Test func move() async throws { + let model = RemindersListsModel() + try await model.$remindersLists.load() + assertInlineSnapshot(of: model.remindersLists.map(\.remindersList.title), as: .customDump) { + """ + [ + [0]: "Personal", + [1]: "Family", + [2]: "Business" + ] + """ + } + + model.move(from: [2], to: 0) + try await model.$remindersLists.load() + assertInlineSnapshot(of: model.remindersLists.map(\.remindersList.title), as: .customDump) { + """ + [ + [0]: "Business", + [1]: "Personal", + [2]: "Family" + ] + """ + } + } + } +} diff --git a/Examples/RemindersTests/RemindersTests.swift b/Examples/RemindersTests/RemindersTests.swift deleted file mode 100644 index 93c623fb..00000000 --- a/Examples/RemindersTests/RemindersTests.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Testing - -struct RemindersTests { - @Test func example() async throws { - } -} diff --git a/Examples/RemindersTests/SearchRemindersTests.swift b/Examples/RemindersTests/SearchRemindersTests.swift new file mode 100644 index 00000000..928faeaf --- /dev/null +++ b/Examples/RemindersTests/SearchRemindersTests.swift @@ -0,0 +1,208 @@ +import Dependencies +import DependenciesTestSupport +import InlineSnapshotTesting +import SnapshotTestingCustomDump +import Testing + +@testable import Reminders + +extension BaseTestSuite { + @MainActor + struct SearchRemindersTests { + @Dependency(\.defaultDatabase) var database + + @Test func basics() async throws { + let model = SearchRemindersModel() + try await model.$reminders.load() + try await model.$completedCount.load() + + #expect(model.completedCount == 0) + assertInlineSnapshot(of: model.reminders, as: .customDump) { + """ + [] + """ + } + + model.searchText = "Take" + try await Task.sleep(for: .seconds(0.1)) + try await model.$reminders.load() + try await model.$completedCount.load() + #expect(model.completedCount == 1) + assertInlineSnapshot(of: model.reminders, as: .customDump) { + """ + [ + [0]: SearchRemindersModel.Row( + isPastDue: false, + notes: "", + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-000000000007), + dueDate: Date(2009-02-17T23:31:30.000Z), + isCompleted: false, + isFlagged: false, + notes: "", + position: 8, + priority: .high, + remindersListID: UUID(00000000-0000-0000-0000-000000000001), + title: "Take out trash" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000001), + color: Color( + provider: ColorBox( + base: ResolvedColorProvider( + color: Color.Resolved( + linearRed: 0.8468733, + linearGreen: 0.25015837, + linearBlue: 0.0343398, + opacity: 1.0 + ) + ) + ) + ), + position: 2, + title: "Family" + ), + tags: [] + ) + ] + """ + } + } + + @Test func showCompleted() async throws { + let model = SearchRemindersModel() + model.searchText = "Take" + await model.showCompletedButtonTapped() + try await Task.sleep(for: .seconds(0.1)) + try await model.$reminders.load() + try await model.$completedCount.load() + + assertInlineSnapshot(of: model.reminders, as: .customDump) { + """ + [ + [0]: SearchRemindersModel.Row( + isPastDue: false, + notes: "", + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-000000000007), + dueDate: Date(2009-02-17T23:31:30.000Z), + isCompleted: false, + isFlagged: false, + notes: "", + position: 8, + priority: .high, + remindersListID: UUID(00000000-0000-0000-0000-000000000001), + title: "Take out trash" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000001), + color: Color( + provider: ColorBox( + base: ResolvedColorProvider( + color: Color.Resolved( + linearRed: 0.8468733, + linearGreen: 0.25015837, + linearBlue: 0.0343398, + opacity: 1.0 + ) + ) + ) + ), + position: 2, + title: "Family" + ), + tags: [] + ), + [1]: SearchRemindersModel.Row( + isPastDue: false, + notes: "", + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-000000000003), + dueDate: Date(2008-08-07T23:31:30.000Z), + isCompleted: true, + isFlagged: false, + notes: "", + position: 4, + priority: nil, + remindersListID: UUID(00000000-0000-0000-0000-000000000000), + title: "Take a walk" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000000), + color: Color( + provider: #1 ColorBox( + base: ResolvedColorProvider( + color: Color.Resolved( + linearRed: 0.06662594, + linearGreen: 0.31854683, + linearBlue: 0.8631573, + opacity: 1.0 + ) + ) + ) + ), + position: 1, + title: "Personal" + ), + tags: [ + [0]: "car", + [1]: "kids", + [2]: "social" + ] + ) + ] + """ + } + } + + @Test func deleteCompleted() async throws { + let model = SearchRemindersModel() + model.searchText = "Take" + await model.showCompletedButtonTapped() + try await Task.sleep(for: .seconds(0.1)) + model.deleteCompletedReminders() + try await model.$reminders.load() + try await model.$completedCount.load() + #expect(model.completedCount == 0) + assertInlineSnapshot(of: model.reminders, as: .customDump) { + """ + [ + [0]: SearchRemindersModel.Row( + isPastDue: false, + notes: "", + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-000000000007), + dueDate: Date(2009-02-17T23:31:30.000Z), + isCompleted: false, + isFlagged: false, + notes: "", + position: 8, + priority: .high, + remindersListID: UUID(00000000-0000-0000-0000-000000000001), + title: "Take out trash" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000001), + color: Color( + provider: ColorBox( + base: ResolvedColorProvider( + color: Color.Resolved( + linearRed: 0.8468733, + linearGreen: 0.25015837, + linearBlue: 0.0343398, + opacity: 1.0 + ) + ) + ) + ), + position: 2, + title: "Family" + ), + tags: [] + ) + ] + """ + } + } + } +} diff --git a/Examples/SyncUpTests/Internal.swift b/Examples/SyncUpTests/Internal.swift new file mode 100644 index 00000000..1939607e --- /dev/null +++ b/Examples/SyncUpTests/Internal.swift @@ -0,0 +1,37 @@ +import Foundation +import SharingGRDB + +@testable import SyncUps + +extension Database { + func seedSyncUpFormTests() throws { + try seed { + SyncUp(id: UUID(1), seconds: 60, theme: .appOrange, title: "Design") + SyncUp(id: UUID(2), seconds: 60 * 10, theme: .periwinkle, title: "Engineering") + SyncUp(id: UUID(3), seconds: 60 * 30, theme: .poppy, title: "Product") + + for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { + Attendee.Draft(name: name, syncUpID: UUID(1)) + } + for name in ["Blob", "Blob Jr"] { + Attendee.Draft(name: name, syncUpID: UUID(2)) + } + for name in ["Blob Sr", "Blob Jr"] { + Attendee.Draft(name: name, syncUpID: UUID(3)) + } + + Meeting.Draft( + date: Date().addingTimeInterval(-60 * 60 * 24 * 7), + syncUpID: UUID(1), + transcript: """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ + exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ + irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia \ + deserunt mollit anim id est laborum. + """ + ) + } + } +} diff --git a/Examples/SyncUpTests/SyncUpFormTests.swift b/Examples/SyncUpTests/SyncUpFormTests.swift index 0273a6e8..a1aa462a 100644 --- a/Examples/SyncUpTests/SyncUpFormTests.swift +++ b/Examples/SyncUpTests/SyncUpFormTests.swift @@ -58,36 +58,3 @@ struct SyncUpFormTests { #expect(attendees.map(\.name) == ["Blob", "Blobby McBlob"]) } } - -extension Database { - fileprivate func seedSyncUpFormTests() throws { - try seed { - SyncUp(id: UUID(1), seconds: 60, theme: .appOrange, title: "Design") - SyncUp(id: UUID(2), seconds: 60 * 10, theme: .periwinkle, title: "Engineering") - SyncUp(id: UUID(3), seconds: 60 * 30, theme: .poppy, title: "Product") - - for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { - Attendee.Draft(name: name, syncUpID: UUID(1)) - } - for name in ["Blob", "Blob Jr"] { - Attendee.Draft(name: name, syncUpID: UUID(2)) - } - for name in ["Blob Sr", "Blob Jr"] { - Attendee.Draft(name: name, syncUpID: UUID(3)) - } - - Meeting.Draft( - date: Date().addingTimeInterval(-60 * 60 * 24 * 7), - syncUpID: UUID(1), - transcript: """ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ - incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \ - exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute \ - irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \ - pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia \ - deserunt mollit anim id est laborum. - """ - ) - } - } -} diff --git a/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved index 945a790c..48061652 100644 --- a/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SharingGRDB.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8a1853fb7dfb1700b9eddcf9296fe2fa1c2a811aeb790ad63b8bf25f80c3f59c", + "originHash" : "1ade2dece187606591a4a1c8235612ac5f68e9bcce36d9af0aa83bf1d9b7b8f6", "pins" : [ { "identity" : "combine-schedulers", @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "fee6aa29908a75437506ddcbe7434c460605b7e6", - "version" : "1.9.1" + "revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596", + "version" : "1.9.2" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "1be8144023c367c5de701a6313ed29a3a10bf59b", - "version" : "1.18.3" + "revision" : "37230a37e83f1b7023be08e1b1a2603fcb1567fb", + "version" : "1.18.4" } }, { From 4c76a69b2bba1192f0828b7a2bf81022d49e49f5 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sun, 1 Jun 2025 15:05:37 -0700 Subject: [PATCH 14/21] more clean up --- Examples/Reminders/ReminderForm.swift | 2 +- Examples/Reminders/Schema.swift | 27 +++++++++++++------------- Examples/RemindersTests/Internal.swift | 24 +++++++++++------------ Examples/SyncUps/Schema.swift | 11 +++++++---- 4 files changed, 34 insertions(+), 30 deletions(-) diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index c2f49c50..a0870afd 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -177,7 +177,7 @@ struct ReminderFormView: View { .execute(db) try ReminderTag.insert( selectedTags.map { tag in - ReminderTag(reminderID: reminderID, tagID: tag.id) + ReminderTag.Draft(reminderID: reminderID, tagID: tag.id) } ) .execute(db) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 5c525a6b..eec240b4 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -84,9 +84,9 @@ extension Tag.TableColumns { @Table("remindersTags") struct ReminderTag: Hashable, Identifiable { + let id: UUID var reminderID: Reminder.ID var tagID: Tag.ID - var id: Self { self } } func appDatabase() throws -> any DatabaseWriter { @@ -171,6 +171,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "remindersTags" ( + "id" TEXT UNIQUE NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "reminderID" TEXT NOT NULL, "tagID" TEXT NOT NULL, @@ -348,18 +349,18 @@ private let logger = Logger(subsystem: "Reminders", category: "Database") Tag(id: tagIDs[4], title: "social") Tag(id: tagIDs[5], title: "night") Tag(id: tagIDs[6], title: "adulting") - ReminderTag(reminderID: reminderIDs[0], tagID: tagIDs[2]) - ReminderTag(reminderID: reminderIDs[0], tagID: tagIDs[3]) - ReminderTag(reminderID: reminderIDs[0], tagID: tagIDs[6]) - ReminderTag(reminderID: reminderIDs[1], tagID: tagIDs[2]) - ReminderTag(reminderID: reminderIDs[1], tagID: tagIDs[3]) - ReminderTag(reminderID: reminderIDs[2], tagID: tagIDs[6]) - ReminderTag(reminderID: reminderIDs[3], tagID: tagIDs[0]) - ReminderTag(reminderID: reminderIDs[3], tagID: tagIDs[1]) - ReminderTag(reminderID: reminderIDs[4], tagID: tagIDs[4]) - ReminderTag(reminderID: reminderIDs[3], tagID: tagIDs[4]) - ReminderTag(reminderID: reminderIDs[10], tagID: tagIDs[4]) - ReminderTag(reminderID: reminderIDs[4], tagID: tagIDs[5]) + ReminderTag.Draft(reminderID: reminderIDs[0], tagID: tagIDs[2]) + ReminderTag.Draft(reminderID: reminderIDs[0], tagID: tagIDs[3]) + ReminderTag.Draft(reminderID: reminderIDs[0], tagID: tagIDs[6]) + ReminderTag.Draft(reminderID: reminderIDs[1], tagID: tagIDs[2]) + ReminderTag.Draft(reminderID: reminderIDs[1], tagID: tagIDs[3]) + ReminderTag.Draft(reminderID: reminderIDs[2], tagID: tagIDs[6]) + ReminderTag.Draft(reminderID: reminderIDs[3], tagID: tagIDs[0]) + ReminderTag.Draft(reminderID: reminderIDs[3], tagID: tagIDs[1]) + ReminderTag.Draft(reminderID: reminderIDs[4], tagID: tagIDs[4]) + ReminderTag.Draft(reminderID: reminderIDs[3], tagID: tagIDs[4]) + ReminderTag.Draft(reminderID: reminderIDs[10], tagID: tagIDs[4]) + ReminderTag.Draft(reminderID: reminderIDs[4], tagID: tagIDs[5]) } } } diff --git a/Examples/RemindersTests/Internal.swift b/Examples/RemindersTests/Internal.swift index 8923b380..387863d1 100644 --- a/Examples/RemindersTests/Internal.swift +++ b/Examples/RemindersTests/Internal.swift @@ -139,18 +139,18 @@ extension Database { Tag(id: UUID(4), title: "social") Tag(id: UUID(5), title: "night") Tag(id: UUID(6), title: "adulting") - ReminderTag(reminderID: UUID(0), tagID: UUID(2)) - ReminderTag(reminderID: UUID(0), tagID: UUID(3)) - ReminderTag(reminderID: UUID(0), tagID: UUID(6)) - ReminderTag(reminderID: UUID(1), tagID: UUID(2)) - ReminderTag(reminderID: UUID(1), tagID: UUID(3)) - ReminderTag(reminderID: UUID(2), tagID: UUID(6)) - ReminderTag(reminderID: UUID(3), tagID: UUID(0)) - ReminderTag(reminderID: UUID(3), tagID: UUID(1)) - ReminderTag(reminderID: UUID(4), tagID: UUID(4)) - ReminderTag(reminderID: UUID(3), tagID: UUID(4)) - ReminderTag(reminderID: UUID(10), tagID: UUID(4)) - ReminderTag(reminderID: UUID(4), tagID: UUID(5)) + ReminderTag.Draft(reminderID: UUID(0), tagID: UUID(2)) + ReminderTag.Draft(reminderID: UUID(0), tagID: UUID(3)) + ReminderTag.Draft(reminderID: UUID(0), tagID: UUID(6)) + ReminderTag.Draft(reminderID: UUID(1), tagID: UUID(2)) + ReminderTag.Draft(reminderID: UUID(1), tagID: UUID(3)) + ReminderTag.Draft(reminderID: UUID(2), tagID: UUID(6)) + ReminderTag.Draft(reminderID: UUID(3), tagID: UUID(0)) + ReminderTag.Draft(reminderID: UUID(3), tagID: UUID(1)) + ReminderTag.Draft(reminderID: UUID(4), tagID: UUID(4)) + ReminderTag.Draft(reminderID: UUID(3), tagID: UUID(4)) + ReminderTag.Draft(reminderID: UUID(10), tagID: UUID(4)) + ReminderTag.Draft(reminderID: UUID(4), tagID: UUID(5)) } } } diff --git a/Examples/SyncUps/Schema.swift b/Examples/SyncUps/Schema.swift index 6c00c138..d07518bf 100644 --- a/Examples/SyncUps/Schema.swift +++ b/Examples/SyncUps/Schema.swift @@ -91,12 +91,15 @@ func appDatabase() throws -> any DatabaseWriter { } #endif } - if context == .live { - let path = URL.documentsDirectory.appending(component: "db.sqlite").path() + if context == .preview { + database = try DatabaseQueue(configuration: configuration) + } else { + let path = + context == .live + ? URL.documentsDirectory.appending(component: "db.sqlite").path() + : URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() logger.info("open \(path)") database = try DatabasePool(path: path, configuration: configuration) - } else { - database = try DatabaseQueue(configuration: configuration) } var migrator = DatabaseMigrator() #if DEBUG From 4640b09691280f058477d2e4a09ed21c07cad3a2 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Mon, 2 Jun 2025 14:46:34 -0700 Subject: [PATCH 15/21] Update Examples/SyncUps/SyncUpDetail.swift Co-authored-by: Stephen Celis --- Examples/SyncUps/SyncUpDetail.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Examples/SyncUps/SyncUpDetail.swift b/Examples/SyncUps/SyncUpDetail.swift index 86751f43..5aef40ad 100644 --- a/Examples/SyncUps/SyncUpDetail.swift +++ b/Examples/SyncUps/SyncUpDetail.swift @@ -155,7 +155,7 @@ struct SyncUpDetailView: View { } Section { - ForEach(model.attendees, id: \.id) { attendee in + ForEach(model.attendees) { attendee in Label(attendee.name, systemImage: "person") } } header: { From 216785284abb190b8d83bcb7ed2f1c507a510fc3 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Mon, 2 Jun 2025 14:46:45 -0700 Subject: [PATCH 16/21] Update Examples/SyncUps/SyncUpDetail.swift Co-authored-by: Stephen Celis --- Examples/SyncUps/SyncUpDetail.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Examples/SyncUps/SyncUpDetail.swift b/Examples/SyncUps/SyncUpDetail.swift index 5aef40ad..93d344af 100644 --- a/Examples/SyncUps/SyncUpDetail.swift +++ b/Examples/SyncUps/SyncUpDetail.swift @@ -135,7 +135,7 @@ struct SyncUpDetailView: View { if !model.meetings.isEmpty { Section { - ForEach(model.meetings, id: \.id) { meeting in + ForEach(model.meetings) { meeting in NavigationLink( value: AppModel.Path.meeting(meeting, attendees: model.attendees) ) { From 2fbd4a79eb5266057bb46a9f336ebb9d49d83955 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Jun 2025 14:57:50 -0700 Subject: [PATCH 17/21] clean up --- Examples/Reminders/ReminderForm.swift | 1 + Examples/Reminders/Schema.swift | 25 +++++-------- Examples/Reminders/SearchReminders.swift | 6 +++- Examples/RemindersTests/Internal.swift | 6 ++-- .../RemindersDetailsTests.swift | 9 +++-- .../RemindersTests/RemindersListsTests.swift | 2 +- .../RemindersTests/SearchRemindersTests.swift | 36 +------------------ Examples/SyncUps/Schema.swift | 6 ++-- 8 files changed, 30 insertions(+), 61 deletions(-) diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index a0870afd..e969b809 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -170,6 +170,7 @@ struct ReminderFormView: View { private func saveButtonTapped() { withErrorReporting { try database.write { db in + var reminder = reminder let reminderID = try Reminder.upsert(reminder).returning(\.id).fetchOne(db)! try ReminderTag .where { $0.reminderID.eq(reminderID) } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index eec240b4..788f8f27 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -1,3 +1,4 @@ +import Dependencies import Foundation import IssueReporting import OSLog @@ -57,10 +58,12 @@ extension Reminder { extension Reminder.TableColumns { var isPastDue: some QueryExpression { - !isCompleted && #sql("coalesce(date(\(dueDate)) < now(), 0)") + @Dependency(\.date.now) var now + return !isCompleted && #sql("coalesce(date(\(dueDate)) < date(\(now)), 0)") } var isToday: some QueryExpression { - !isCompleted && #sql("coalesce(date(\(dueDate)) = now(), 0)") + @Dependency(\.date.now) var now + return !isCompleted && #sql("coalesce(date(\(dueDate)) = date(\(now)), 0)") } var isScheduled: some QueryExpression { !isCompleted && dueDate.isNot(nil) @@ -104,16 +107,6 @@ func appDatabase() throws -> any DatabaseWriter { } } #endif - db.add( - function: DatabaseFunction("now", argumentCount: 0) { _ in - @Dependency(\.date.now) var now - return now.formatted( - .iso8601.year().month().day() - .dateTimeSeparator(.space) - .time(includingFractionalSeconds: true) - ) - } - ) } if context == .preview { database = try DatabaseQueue(configuration: configuration) @@ -133,7 +126,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "remindersLists" ( - "id" TEXT UNIQUE NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "color" INTEGER NOT NULL DEFAULT \(raw: 0x4a99_ef00), "position" INTEGER NOT NULL DEFAULT 0, "title" TEXT NOT NULL @@ -144,7 +137,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "reminders" ( - "id" TEXT UNIQUE NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "dueDate" TEXT, "isCompleted" INTEGER NOT NULL DEFAULT 0, "isFlagged" INTEGER NOT NULL DEFAULT 0, @@ -162,7 +155,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "tags" ( - "id" TEXT UNIQUE NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "title" TEXT NOT NULL COLLATE NOCASE ) STRICT """ @@ -171,7 +164,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "remindersTags" ( - "id" TEXT UNIQUE NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "reminderID" TEXT NOT NULL, "tagID" TEXT NOT NULL, diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index b0ee752f..0cc0ed5a 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -53,7 +53,11 @@ class SearchRemindersModel { try await $reminders.load( Reminder .searching(searchText) - .where { showCompletedInSearchResults || !$0.isCompleted } + .where { + if !showCompletedInSearchResults { + !$0.isCompleted + } + } .order { ($0.isCompleted, $0.dueDate) } .withTags .join(RemindersList.all) { $0.remindersListID.eq($3.id) } diff --git a/Examples/RemindersTests/Internal.swift b/Examples/RemindersTests/Internal.swift index 387863d1..82a02504 100644 --- a/Examples/RemindersTests/Internal.swift +++ b/Examples/RemindersTests/Internal.swift @@ -7,7 +7,7 @@ import Testing @Suite( .dependencies { - $0.date.now = Date(timeIntervalSince1970: 1234567890) + $0.date.now = baseDate $0.defaultDatabase = try Reminders.appDatabase() try $0.defaultDatabase.write { try $0.seedTestData() } }, @@ -17,7 +17,7 @@ struct BaseTestSuite {} extension Database { func seedTestData() throws { - let baseDate = Date(timeIntervalSince1970: 1234567890) + let baseDate = baseDate try seed { RemindersList( id: UUID(0), @@ -154,3 +154,5 @@ extension Database { } } } + +private let baseDate = Date(timeIntervalSince1970: 1234567890) diff --git a/Examples/RemindersTests/RemindersDetailsTests.swift b/Examples/RemindersTests/RemindersDetailsTests.swift index b5f4c03c..89529d88 100644 --- a/Examples/RemindersTests/RemindersDetailsTests.swift +++ b/Examples/RemindersTests/RemindersDetailsTests.swift @@ -83,7 +83,7 @@ extension BaseTestSuite { position: 1, title: "Personal" ), - isPastDue: true, + isPastDue: false, notes: "Ask about diet", tags: [ [0]: "adulting" @@ -118,7 +118,7 @@ extension BaseTestSuite { position: 1, title: "Personal" ), - isPastDue: true, + isPastDue: false, notes: "", tags: [ [0]: "social", @@ -366,7 +366,10 @@ extension BaseTestSuite { try await model.$reminderRows.load() assertInlineSnapshot(of: model.reminderRows.map(\.reminder.title), as: .customDump) { """ - [] + [ + [0]: "Doctor appointment", + [1]: "Buy concert tickets" + ] """ } } diff --git a/Examples/RemindersTests/RemindersListsTests.swift b/Examples/RemindersTests/RemindersListsTests.swift index b6ee9931..86dc485e 100644 --- a/Examples/RemindersTests/RemindersListsTests.swift +++ b/Examples/RemindersTests/RemindersListsTests.swift @@ -86,7 +86,7 @@ extension BaseTestSuite { allCount: 8, flaggedCount: 2, scheduledCount: 7, - todayCount: 0 + todayCount: 2 ) """ } diff --git a/Examples/RemindersTests/SearchRemindersTests.swift b/Examples/RemindersTests/SearchRemindersTests.swift index 928faeaf..13ec0b9c 100644 --- a/Examples/RemindersTests/SearchRemindersTests.swift +++ b/Examples/RemindersTests/SearchRemindersTests.swift @@ -30,41 +30,7 @@ extension BaseTestSuite { #expect(model.completedCount == 1) assertInlineSnapshot(of: model.reminders, as: .customDump) { """ - [ - [0]: SearchRemindersModel.Row( - isPastDue: false, - notes: "", - reminder: Reminder( - id: UUID(00000000-0000-0000-0000-000000000007), - dueDate: Date(2009-02-17T23:31:30.000Z), - isCompleted: false, - isFlagged: false, - notes: "", - position: 8, - priority: .high, - remindersListID: UUID(00000000-0000-0000-0000-000000000001), - title: "Take out trash" - ), - remindersList: RemindersList( - id: UUID(00000000-0000-0000-0000-000000000001), - color: Color( - provider: ColorBox( - base: ResolvedColorProvider( - color: Color.Resolved( - linearRed: 0.8468733, - linearGreen: 0.25015837, - linearBlue: 0.0343398, - opacity: 1.0 - ) - ) - ) - ), - position: 2, - title: "Family" - ), - tags: [] - ) - ] + [] """ } } diff --git a/Examples/SyncUps/Schema.swift b/Examples/SyncUps/Schema.swift index d07518bf..2d66f05d 100644 --- a/Examples/SyncUps/Schema.swift +++ b/Examples/SyncUps/Schema.swift @@ -109,7 +109,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "syncUps" ( - "id" TEXT UNIQUE NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "seconds" INTEGER NOT NULL DEFAULT 300, "theme" TEXT NOT NULL DEFAULT \(raw: Theme.bubblegum.rawValue), "title" TEXT NOT NULL @@ -120,7 +120,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "attendees" ( - "id" TEXT UNIQUE NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "name" TEXT NOT NULL, "syncUpID" INTEGER NOT NULL, @@ -132,7 +132,7 @@ func appDatabase() throws -> any DatabaseWriter { try #sql( """ CREATE TABLE "meetings" ( - "id" TEXT UNIQUE NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), + "id" TEXT PRIMARY KEY NOT NULL ON CONFLICT REPLACE DEFAULT (uuid()), "date" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP UNIQUE, "syncUpID" INTEGER NOT NULL, "transcript" TEXT NOT NULL, From fc7577875b259c5771cf089e7e2e7b0f99a81aa6 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Jun 2025 14:58:29 -0700 Subject: [PATCH 18/21] wip --- .../RemindersTests/SearchRemindersTests.swift | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/Examples/RemindersTests/SearchRemindersTests.swift b/Examples/RemindersTests/SearchRemindersTests.swift index 13ec0b9c..928faeaf 100644 --- a/Examples/RemindersTests/SearchRemindersTests.swift +++ b/Examples/RemindersTests/SearchRemindersTests.swift @@ -30,7 +30,41 @@ extension BaseTestSuite { #expect(model.completedCount == 1) assertInlineSnapshot(of: model.reminders, as: .customDump) { """ - [] + [ + [0]: SearchRemindersModel.Row( + isPastDue: false, + notes: "", + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-000000000007), + dueDate: Date(2009-02-17T23:31:30.000Z), + isCompleted: false, + isFlagged: false, + notes: "", + position: 8, + priority: .high, + remindersListID: UUID(00000000-0000-0000-0000-000000000001), + title: "Take out trash" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000001), + color: Color( + provider: ColorBox( + base: ResolvedColorProvider( + color: Color.Resolved( + linearRed: 0.8468733, + linearGreen: 0.25015837, + linearBlue: 0.0343398, + opacity: 1.0 + ) + ) + ) + ), + position: 2, + title: "Family" + ), + tags: [] + ) + ] """ } } From 995a4599ef836da6e7be079d123ea52fd4eaf0e8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 3 Jun 2025 13:36:49 -0700 Subject: [PATCH 19/21] wio; --- .../Articles/PreparingDatabase.md | 105 ++++++++++++------ 1 file changed, 70 insertions(+), 35 deletions(-) diff --git a/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md b/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md index baa1d39b..d23deabe 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md +++ b/Sources/SharingGRDBCore/Documentation.docc/Articles/PreparingDatabase.md @@ -54,18 +54,25 @@ cascading action (such as delete). We further recommend that you enable query tracing to log queries that are executed in your application. This can be handy for tracking down long-running queries, or when more queries execute than you expect. We also recommend only doing this in debug builds to avoid leaking sensitive -information when the app is running on a user's device: +information when the app is running on a user's device, and we further recommned using OSLog +when running your app in the simulator/device and using `Swift.print` in previews: ```diff -+import OSLog + import OSLog + import SharingGRDB func appDatabase() -> any DatabaseWriter { ++ @Dependency(\.context) var context var configuration = Configuration() configuration.foreignKeysEnabled = true + #if DEBUG + configuration.prepareDatabase { db in + db.trace(options: .profile) { -+ logger.debug("\($0.expandedDescription)") ++ if context == .preview { ++ print("\($0.expandedDescription)") ++ } else { ++ logger.debug("\($0.expandedDescription)") ++ } + } + } + #endif @@ -78,8 +85,12 @@ information when the app is running on a user's device: > sensitive data that you may not want to leak. In this case we feel it is OK because everything > is surrounded in `#if DEBUG`, but it is something to be careful of in your own apps. -> Tip: OSLog allows you to more flexibly filter logs in Xcode, but if you are on a non-Apple -> platform you can use Swift's `print` function, instead. + +> Tip: `@Dependency(\.context)` comes from the [Swift Dependencies][swift-dependencies-gh] library, +> which SharingGRDB uses to share its database connection across fetch keys. It allows you to +> inspect the context your app is running in: live, preview or test. + +[swift-dependencies-gh]: https://github.com/pointfreeco/swift-dependencies For more information on configuring tracing, see [GRDB's documentation][trace-docs] on the matter. @@ -95,12 +106,17 @@ way to do this is to construct the database connection for a path on the file sy ```diff -func appDatabase() -> any DatabaseWriter { +func appDatabase() throws -> any DatabaseWriter { + @Dependency(\.context) var context var configuration = Configuration() configuration.foreignKeysEnabled = true #if DEBUG configuration.prepareDatabase { db in db.trace(options: .profile) { - logger.debug("\($0.expandedDescription)") + if context == .preview { + print("\($0.expandedDescription)") + } else { + logger.debug("\($0.expandedDescription)") + } } } #endif @@ -111,21 +127,27 @@ way to do this is to construct the database connection for a path on the file sy } ``` -However, in tests and Xcode previews we would like to use an in-memory database so that each test -and preview gets their own sandboxed database. In fact, previews can crash if we attempt to load a -database from the file system. +However, this can be improved. First, this code will crash if it is executed in Xcode previews +because SQLite is unable to form a connection to a database on disk in a preview context. And +second, in tests we should write this databadse to the temporary directoy on disk with a unique +name so that each test gets a fresh database and so that multiple tests can run in parallel. To fix this we can use `@Dependency(\.context)` to determine if we are in a "live" application context or if we're in a preview or test. ```diff func appDatabase() -> any DatabaseWriter { + @Dependency(\.context) var context var configuration = Configuration() configuration.foreignKeysEnabled = true #if DEBUG configuration.prepareDatabase { db in db.trace(options: .profile) { - logger.debug("\($0.expandedDescription)") + if context == .preview { + print("\($0.expandedDescription)") + } else { + logger.debug("\($0.expandedDescription)") + } } } #endif @@ -138,6 +160,9 @@ context or if we're in a preview or test. + let path = URL.documentsDirectory.appending(component: "db.sqlite").path() + logger.info("open \(path)") + database = try DatabasePool(path: path, configuration: configuration) ++ } else if context == .test { ++ let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() ++ database = try DatabasePool(path: path, configuration: configuration) + } else { + database = try DatabaseQueue(configuration: configuration) + } @@ -145,11 +170,6 @@ context or if we're in a preview or test. } ``` -> Tip: `@Dependency(\.context)` comes from the [Swift Dependencies][swift-dependencies-gh] library, -> which SharingGRDB uses to share its database connection across fetch keys. - -[swift-dependencies-gh]: https://github.com/pointfreeco/swift-dependencies - ### Step 4: Migrate database Now that the database connection is created we can migrate the database. GRDB provides all the @@ -159,21 +179,28 @@ database connection: ```diff func appDatabase() throws -> any DatabaseWriter { + @Dependency(\.context) var context var configuration = Configuration() configuration.foreignKeysEnabled = true #if DEBUG configuration.prepareDatabase { db in db.trace(options: .profile) { - logger.debug("\($0.expandedDescription)") + if context == .preview { + print("\($0.expandedDescription)") + } else { + logger.debug("\($0.expandedDescription)") + } } } #endif - @Dependency(\.context) var context let database: any DatabaseWriter if context == .live { let path = URL.documentsDirectory.appending(component: "db.sqlite").path() logger.info("open \(path)") database = try DatabasePool(path: path, configuration: configuration) + } else if context == .test { + let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() + database = try DatabasePool(path: path, configuration: configuration) } else { database = try DatabaseQueue(configuration: configuration) } @@ -199,26 +226,34 @@ we have just written in one snippet: ```swift import OSLog +import SharingGRDB func appDatabase() throws -> any DatabaseWriter { - var configuration = Configuration() - configuration.foreignKeysEnabled = true - #if DEBUG - configuration.prepareDatabase { db in - db.trace(options: .profile) { - logger.debug("\($0.expandedDescription)") - } - } - #endif - @Dependency(\.context) var context - let database: any DatabaseWriter - if context == .live { - let path = URL.documentsDirectory.appending(component: "db.sqlite").path() - logger.info("open \(path)") - database = try DatabasePool(path: path, configuration: configuration) - } else { - database = try DatabaseQueue(configuration: configuration) - } + @Dependency(\.context) var context + var configuration = Configuration() + configuration.foreignKeysEnabled = true + #if DEBUG + configuration.prepareDatabase { db in + db.trace(options: .profile) { + if context == .preview { + print("\($0.expandedDescription)") + } else { + logger.debug("\($0.expandedDescription)") + } + } + } + #endif + let database: any DatabaseWriter + if context == .live { + let path = URL.documentsDirectory.appending(component: "db.sqlite").path() + logger.info("open \(path)") + database = try DatabasePool(path: path, configuration: configuration) + } else if context == .test { + let path = URL.temporaryDirectory.appending(component: "\(UUID().uuidString)-db.sqlite").path() + database = try DatabasePool(path: path, configuration: configuration) + } else { + database = try DatabaseQueue(configuration: configuration) + } var migrator = DatabaseMigrator() #if DEBUG migrator.eraseDatabaseOnSchemaChange = true From e3518478370783b3287f9f7b05f6145118bd7e3b Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 3 Jun 2025 13:49:49 -0700 Subject: [PATCH 20/21] readme update --- README.md | 43 ++++++++++++++++++- .../Documentation.docc/SharingGRDBCore.md | 39 +++++++++++++++-- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3be7df3c..34ccadfd 100644 --- a/README.md +++ b/README.md @@ -154,16 +154,54 @@ struct MyApp: App { > [Preparing a SQLite database][preparing-db-article]. This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies, like -[`@FetchAll`][fetchall-docs] and [`@FetchOne`][fetchone-docs]: +[`@FetchAll`][fetchall-docs] and [`@FetchOne`][fetchone-docs], which are similar to SwiftData's +`@Query` macro, but more powerful: + + + + + + + + + + +
SharingGRDBSwiftData
```swift @FetchAll var items: [Item] -@FetchOne(Item.where(\.isInStock).count()) +@FetchAll(Item.sort(by: \.title)) +var items + +@FetchAll(Item.where(\.isInStock)) +var items + +@FetchOne(Item.count()) var inStockItemsCount = 0 + +``` + + + +```swift +@Query +var items: [Item] + +@Query(sort: [SortDescriptor(\.title)]) +var items: [Item] + +// No @Query equivalent of filtering +// by 'isInStock: Bool' + +// No @Query equivalent of counting +// entries in database without loading +// all entries. ``` +
+ And you can access this database throughout your application in a way similar to how one accesses a model context, via a property wrapper: @@ -196,6 +234,7 @@ var modelContext let newItem = Item(/* ... */) modelContext.insert(newItem) try modelContext.save() + ``` diff --git a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md b/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md index 289909be..4db1e024 100644 --- a/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md +++ b/Sources/SharingGRDBCore/Documentation.docc/SharingGRDBCore.md @@ -105,11 +105,42 @@ in SwiftData: > Note: For more information on preparing a SQLite database, see . This `defaultDatabase` connection is used implicitly by SharingGRDB's strategies, like -[`@FetchAll`](): +[`@FetchAll`](), which are similar to SwiftData's +`@Query` macro, but more powerful: -```swift -@FetchAll var items: [Item] -``` +@Row { + @Column { + ```swift + @FetchAll + var items: [Item] + + @FetchAll(Item.sort(by: \.title)) + var items + + @FetchAll(Item.where(\.isInStock)) + var items + + @FetchOne(Item.count()) + var inStockItemsCount = 0 + ``` + } + @Column { + ```swift + @Query + var items: [Item] + + @Query(sort: [SortDescriptor(\.title)]) + var items: [Item] + + // No @Query equivalent of filtering + // by 'isInStock: Bool' + + // No @Query equivalent of counting + // entries in database without loading + // all entries. + ``` + } +} And you can access this database throughout your application in a way similar to how one accesses a model context, via a property wrapper: From 039c677c78c42bdcd2c631d88057819d8b8b527d Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 3 Jun 2025 21:15:04 -0700 Subject: [PATCH 21/21] fix --- Examples/RemindersTests/SearchRemindersTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Examples/RemindersTests/SearchRemindersTests.swift b/Examples/RemindersTests/SearchRemindersTests.swift index 928faeaf..5fbb99ed 100644 --- a/Examples/RemindersTests/SearchRemindersTests.swift +++ b/Examples/RemindersTests/SearchRemindersTests.swift @@ -24,9 +24,9 @@ extension BaseTestSuite { } model.searchText = "Take" - try await Task.sleep(for: .seconds(0.1)) try await model.$reminders.load() try await model.$completedCount.load() + try await Task.sleep(for: .seconds(0.1)) #expect(model.completedCount == 1) assertInlineSnapshot(of: model.reminders, as: .customDump) { """