diff --git a/Examples/Reminders/RemindersListDetail.swift b/Examples/Reminders/RemindersDetail.swift similarity index 86% rename from Examples/Reminders/RemindersListDetail.swift rename to Examples/Reminders/RemindersDetail.swift index 93483d22..94b702de 100644 --- a/Examples/Reminders/RemindersListDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -2,7 +2,7 @@ import CasePaths import SharingGRDB import SwiftUI -struct RemindersListDetailView: View { +struct RemindersDetailView: View { @FetchAll private var reminderStates: [ReminderState] @AppStorage private var ordering: Ordering @AppStorage private var showCompleted: Bool @@ -46,6 +46,9 @@ struct RemindersListDetailView: View { 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 @@ -126,13 +129,38 @@ struct RemindersListDetailView: 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) + } + } + ordering = .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") } @@ -157,7 +185,7 @@ struct RemindersListDetailView: View { fileprivate var remindersQuery: some StructuredQueriesCore.Statement { let query = - Reminder + Reminder .where { if !showCompleted { !$0.isCompleted @@ -167,6 +195,7 @@ struct RemindersListDetailView: View { .order { switch ordering { case .dueDate: $0.dueDate + case .manual: $0.position case .priority: ($0.priority.desc(), $0.isFlagged.desc()) case .title: $0.title } @@ -208,7 +237,7 @@ struct RemindersListDetailView: View { } } -extension RemindersListDetailView.DetailType { +extension RemindersDetailView.DetailType { fileprivate var id: String { switch self { case .all: "all" @@ -249,7 +278,7 @@ extension RemindersListDetailView.DetailType { } } -struct RemindersListDetailPreview: PreviewProvider { +struct RemindersDetailPreview: PreviewProvider { static var previews: some View { let (remindersList, tag) = try! prepareDependencies { $0.defaultDatabase = try Reminders.appDatabase() @@ -260,14 +289,14 @@ struct RemindersListDetailPreview: PreviewProvider { ) } } - let detailTypes: [RemindersListDetailView.DetailType] = [ + let detailTypes: [RemindersDetailView.DetailType] = [ .all, .list(remindersList), .tags([tag]), ] ForEach(detailTypes, id: \.self) { detailType in NavigationStack { - RemindersListDetailView(detailType: detailType) + RemindersDetailView(detailType: detailType) } .previewDisplayName(detailType.navigationTitle) } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index 35a030e4..e9e6a241 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -37,7 +37,7 @@ struct RemindersListsView: View { private var stats = Stats() @State private var destination: Destination? - @State private var remindersDetailType: RemindersListDetailView.DetailType? + @State private var remindersDetailType: RemindersDetailView.DetailType? @State private var searchText = "" @Dependency(\.defaultDatabase) private var database @@ -124,13 +124,13 @@ struct RemindersListsView: View { Section { ForEach(remindersLists) { state in NavigationLink { - RemindersListDetailView(detailType: .list(state.remindersList)) + RemindersDetailView(detailType: .list(state.remindersList)) } label: { RemindersListRow( remindersCount: state.remindersCount, remindersList: state.remindersList ) - } + } } .onMove { indexSet, index in move(from: indexSet, to: index) @@ -148,7 +148,7 @@ struct RemindersListsView: View { Section { ForEach(tags) { tag in NavigationLink { - RemindersListDetailView(detailType: .tags([tag])) + RemindersDetailView(detailType: .tags([tag])) } label: { TagRow(tag: tag) } @@ -211,7 +211,7 @@ struct RemindersListsView: View { } .searchable(text: $searchText) .navigationDestination(item: $remindersDetailType) { detailType in - RemindersListDetailView(detailType: detailType) + RemindersDetailView(detailType: detailType) } } @@ -225,7 +225,8 @@ struct RemindersListsView: View { .update { let ids = Array(ids.enumerated()) let (first, rest) = (ids.first!, ids.dropFirst()) - $0.position = rest + $0.position = + rest .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in cases.when(id.element, then: id.offset) } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index a0e89132..bfd7d26d 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -23,6 +23,7 @@ struct Reminder: Equatable, Identifiable { var notes = "" var priority: Priority? var remindersListID: Int + var position = 0 var title = "" } @@ -86,17 +87,21 @@ struct ReminderTag: Hashable, Identifiable { } 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 == .live { + logger.debug("\($0.expandedDescription)") + } else { + print("\($0.expandedDescription)") + } } #endif } - @Dependency(\.context) var context if context == .live { let path = URL.documentsDirectory.appending(component: "db.sqlite").path() logger.info("open \(path)") @@ -179,6 +184,44 @@ 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" + AFTER INSERT ON "reminders" + FOR EACH ROW BEGIN + UPDATE "reminders" + SET "position" = (SELECT max("position") + 1 FROM "reminders") + WHERE "id" = NEW."id"; + END + """ + ) + .execute(db) + } #if DEBUG && targetEnvironment(simulator) if context != .test { migrator.registerMigration("Seed sample data") { db in