Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Examples/Reminders/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ A rebuild of many of the features from Apple's [Reminders app][reminders-app-sto
for reminders, lists and tags in a SQLite database, and uses foreign keys to express one-to-many
and many-to-many relationships between the entities.

The sample configures a default undo manager so local and synced changes can be undone/redone from
the screen menu using Undo/Redo entries that trigger immediately. It also binds to Apple's
`UndoManager` so system undo gestures (including shake to undo) work with the same stack.

It also demonstrates how to perform very advanced queries in SQLite that would be impossible in
SwiftData, such as using SQLite's `group_concat` function to fetch all reminders along with a
comma-separated list of all of its tags. SQLite is an incredibly powerful language, and one should
Expand Down
6 changes: 5 additions & 1 deletion Examples/Reminders/ReminderForm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,11 @@ struct ReminderFormView: View {

private func saveButtonTapped() {
withErrorReporting {
try database.write { db in
try database.writeWithUndoGroup(
reminder.id == nil
? "Create reminder"
: "Edit reminder"
) { db in
let reminderID = try Reminder.upsert { reminder }
.returning(\.id)
.fetchOne(db)!
Expand Down
14 changes: 11 additions & 3 deletions Examples/Reminders/ReminderRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,18 @@ struct ReminderRow: View {
.swipeActions {
Button("Delete", role: .destructive) {
withErrorReporting {
try database.write { db in
try database.writeWithUndoGroup("Delete reminder") { db in
try Reminder.delete(reminder).execute(db)
}
}
}
Button(reminder.isFlagged ? "Unflag" : "Flag") {
withErrorReporting {
try database.write { db in
try database.writeWithUndoGroup(
reminder.isFlagged
? "Unflag reminder"
: "Flag reminder"
) { db in
try Reminder
.find(reminder.id)
.update { $0.isFlagged.toggle() }
Expand All @@ -106,7 +110,11 @@ struct ReminderRow: View {

private func completeButtonTapped() {
withErrorReporting {
try database.write { db in
try database.writeWithUndoGroup(
reminder.isCompleted
? "Mark reminder incomplete"
: "Mark reminder complete"
) { db in
try Reminder
.find(reminder.id)
.update { $0.toggleStatus() }
Expand Down
116 changes: 115 additions & 1 deletion Examples/Reminders/RemindersApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ struct RemindersApp: App {
static let model = RemindersListsModel()

@State var syncEngineDelegate = RemindersSyncEngineDelegate()
@State var undoManagerDelegate = RemindersUndoManagerDelegate()

init() {
if context == .live {
try! prepareDependencies {
try $0.bootstrapDatabase(syncEngineDelegate: syncEngineDelegate)
try $0.bootstrapDatabase(
syncEngineDelegate: syncEngineDelegate,
undoManagerDelegate: undoManagerDelegate
)
}
}
}
Expand All @@ -29,6 +33,7 @@ struct RemindersApp: App {
NavigationStack {
RemindersListsView(model: Self.model)
}
.bindSQLiteUndoManagerToSystemUndo()
.alert(
"Reset local data?",
isPresented: $syncEngineDelegate.isDeleteLocalDataAlertPresented
Expand All @@ -46,6 +51,18 @@ struct RemindersApp: App {
"""
)
}
.alert(item: $undoManagerDelegate.confirmationRequest) { request in
Alert(
title: Text(request.title),
message: Text(request.message),
primaryButton: .destructive(Text(request.confirmButtonTitle)) {
undoManagerDelegate.respondToConfirmation(confirmed: true)
},
secondaryButton: .cancel {
undoManagerDelegate.respondToConfirmation(confirmed: false)
}
)
}
}
}
}
Expand All @@ -70,6 +87,103 @@ class RemindersSyncEngineDelegate: SyncEngineDelegate {
}
}

@MainActor
@Observable
final class RemindersUndoManagerDelegate: SQLiteData.UndoManagerDelegate {
enum ConfirmationReason {
/// The group itself came from syncing.
case syncOrigin
/// The group is local but remote sync changes have arrived since.
case syncChangesSinceRedo
}

struct ConfirmationRequest: Identifiable {
let action: UndoAction
let group: UndoGroup
let reason: ConfirmationReason
var id: UUID { group.id }
var title: String {
switch action {
case .undo: "Undo \"\(group.description)\"?"
case .redo: "Redo \"\(group.description)\"?"
}
}
var message: String {
switch reason {
case .syncOrigin:
"This change came from syncing. Are you sure you want to continue?"
case .syncChangesSinceRedo:
"""
Changes from another device or user have been applied since this action and could \
potentially conflict with the changes you are trying to redo.
"""
}
}
var confirmButtonTitle: String {
switch action {
case .undo: "Undo"
case .redo: "Redo"
}
}
}

var confirmationRequest: ConfirmationRequest?
private var confirmationContinuation: CheckedContinuation<Bool, Never>?

func undoManager(
_ undoManager: SQLiteData.UndoManager,
willPerform action: UndoAction,
for group: UndoGroup,
performAction: @isolated(any) @Sendable () async throws -> Void
) async throws {
guard let reason = confirmationReason(
undoManager: undoManager, action: action, group: group
) else {
try await performAction()
return
}
if await requestConfirmation(action: action, group: group, reason: reason) {
try await performAction()
}
}

func respondToConfirmation(confirmed: Bool) {
confirmationContinuation?.resume(returning: confirmed)
confirmationContinuation = nil
confirmationRequest = nil
}

private func confirmationReason(
undoManager: SQLiteData.UndoManager,
action: UndoAction,
group: UndoGroup
) -> ConfirmationReason? {
if action == .undo && group.isSharedZoneChange {
return .syncOrigin
}
if action == .redo && undoManager.hasSyncChangesSince(group) {
return .syncChangesSinceRedo
}
return nil
}

private func requestConfirmation(
action: UndoAction,
group: UndoGroup,
reason: ConfirmationReason
) async -> Bool {
if confirmationContinuation != nil {
respondToConfirmation(confirmed: false)
}
return await withCheckedContinuation { continuation in
confirmationContinuation = continuation
confirmationRequest = ConfirmationRequest(
action: action, group: group, reason: reason
)
}
}
}

class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject {
func application(
_ application: UIApplication,
Expand Down
5 changes: 4 additions & 1 deletion Examples/Reminders/RemindersDetail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ class RemindersDetailModel: HashableObject {

func move(from source: IndexSet, to destination: Int) async {
withErrorReporting {
try database.write { db in
try database.writeWithUndoGroup("Reorder reminders") { db in
var ids = reminderRows.map(\.reminder.id)
ids.move(fromOffsets: source, toOffset: destination)
try Reminder
Expand Down Expand Up @@ -252,6 +252,9 @@ struct RemindersDetailView: View {
}
}
Menu {
UndoMenuItems()
.tint(model.detailType.color)
Divider()
Group {
Menu {
ForEach(RemindersDetailModel.Ordering.allCases, id: \.self) { ordering in
Expand Down
6 changes: 5 additions & 1 deletion Examples/Reminders/RemindersListForm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ struct RemindersListForm: View {
Button("Save") {
Task { [remindersList, coverImageData] in
await withErrorReporting {
try await database.write { db in
try await database.writeWithUndoGroup(
remindersList.id == nil
? "Create list"
: "Edit list"
) { db in
let remindersListID =
try RemindersList
.upsert { remindersList }
Expand Down
2 changes: 1 addition & 1 deletion Examples/Reminders/RemindersListRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ struct RemindersListRow: View {
.swipeActions {
Button {
withErrorReporting {
try database.write { db in
try database.writeWithUndoGroup("Delete list") { db in
try RemindersList.delete(remindersList)
.execute(db)
}
Expand Down
65 changes: 53 additions & 12 deletions Examples/Reminders/RemindersLists.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ class RemindersListsModel {

@ObservationIgnored
@Dependency(\.defaultDatabase) private var database
@ObservationIgnored
@Dependency(\.defaultUndoManager) private var undoManager
@ObservationIgnored
private var undoEventsTask: Task<Void, Never>?

func statTapped(_ detailType: RemindersDetailModel.DetailType) {
destination = .detail(RemindersDetailModel(detailType: detailType))
Expand All @@ -81,7 +85,7 @@ class RemindersListsModel {
func deleteTags(atOffsets offsets: IndexSet) {
withErrorReporting {
let tagTitles = offsets.map { tags[$0].title }
try database.write { db in
try database.writeWithUndoGroup("Delete tags") { db in
try Tag
.where { $0.title.in(tagTitles) }
.delete()
Expand All @@ -91,6 +95,7 @@ class RemindersListsModel {
}

func onAppear() {
observeUndoEventsIfNeeded()
withErrorReporting {
try Tips.configure()
}
Expand Down Expand Up @@ -121,7 +126,7 @@ class RemindersListsModel {

func move(from source: IndexSet, to destination: Int) {
withErrorReporting {
try database.write { db in
try database.writeWithUndoGroup("Reorder lists") { db in
var ids = remindersLists.map(\.remindersList.id)
ids.move(fromOffsets: source, toOffset: destination)
try RemindersList
Expand All @@ -143,12 +148,46 @@ class RemindersListsModel {

#if DEBUG
func seedDatabaseButtonTapped() {
withErrorReporting {
try database.seedSampleData()
Task {
withErrorReporting {
try database.writeWithoutUndoGroup {
try database.seedSampleData()
}
}
}
}
#endif

deinit {
undoEventsTask?.cancel()
}

private func observeUndoEventsIfNeeded() {
guard undoEventsTask == nil, let undoManager else { return }
undoEventsTask = Task { [weak self] in
guard let self else { return }
for await event in undoManager.events {
await self.handleUndoEvent(event)
}
}
}

private func handleUndoEvent(_ event: UndoEvent) async {
guard event.kind == .undo else { return }
guard event.affectedRows.contains(where: { $0.tableName == RemindersList.tableName }) else { return }
guard case let .detail(detailModel)? = destination else { return }
guard case let .remindersList(remindersList) = detailModel.detailType else { return }

await withErrorReporting {
let isStillPresent = try await database.read { db in
try RemindersList.find(remindersList.id).fetchOne(db) != nil
}
if !isStillPresent {
destination = nil
}
}
}

@CasePathable
enum Destination {
case detail(RemindersDetailModel)
Expand Down Expand Up @@ -312,9 +351,11 @@ struct RemindersListsView: View {
}
.listStyle(.insetGrouped)
.toolbar {
#if DEBUG
ToolbarItem(placement: .automatic) {
Menu {
ToolbarItem(placement: .primaryAction) {
Menu {
UndoMenuItems()
#if DEBUG
Divider()
Button {
model.seedDatabaseButtonTapped()
} label: {
Expand All @@ -335,12 +376,12 @@ struct RemindersListsView: View {
Text("\(syncEngine.isRunning ? "Stop" : "Start") synchronizing")
Image(systemName: syncEngine.isRunning ? "stop" : "play")
}
} label: {
Image(systemName: "ellipsis.circle")
}
.popoverTip(model.seedDatabaseTip)
#endif
} label: {
Image(systemName: "ellipsis.circle")
}
#endif
.popoverTip(model.seedDatabaseTip)
}
ToolbarItem(placement: .bottomBar) {
HStack {
Button {
Expand Down
Loading