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
? LocalizedStringKey("Create reminder")
: LocalizedStringKey("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(LocalizedStringKey("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
? LocalizedStringKey("Unflag reminder")
: LocalizedStringKey("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
? LocalizedStringKey("Mark reminder incomplete")
: LocalizedStringKey("Mark reminder complete")
) { db in
try Reminder
.find(reminder.id)
.update { $0.toggleStatus() }
Expand Down
101 changes: 100 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,88 @@ class RemindersSyncEngineDelegate: SyncEngineDelegate {
}
}

@MainActor
@Observable
final class RemindersUndoManagerDelegate: UndoManagerDelegate {
struct ConfirmationRequest: Identifiable {
let action: UndoAction
let group: UndoGroup
var id: UUID { group.id }
var title: String {
switch action {
case .undo: "Undo \"\(group.description)\"?"
case .redo: "Redo \"\(group.description)\"?"
}
}
var message: String {
"This change came from \(originDescription). Are you sure you want to continue?"
}
var confirmButtonTitle: String {
switch action {
case .undo: "Undo"
case .redo: "Redo"
}
}

private var originDescription: String {
var parts: [String] = []
if group.deviceID != SQLiteUndoManager.defaultDeviceID {
if group.deviceID == "sqlitedata-sync" {
parts.append("another device")
} else {
parts.append("device \(group.deviceID)")
}
}
if
let userRecordName = group.userRecordName
{
parts.append("user \(userRecordName)")
}
return parts.isEmpty ? "this device" : parts.joined(separator: " and ")
}
}

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

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

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

private func shouldConfirm(for group: UndoGroup) -> Bool {
let isOtherDevice = group.deviceID != SQLiteUndoManager.defaultDeviceID
let isOtherUser = group.userRecordName != nil
return isOtherDevice || isOtherUser
}

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

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(LocalizedStringKey("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
? LocalizedStringKey("Create list")
: LocalizedStringKey("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(LocalizedStringKey("Delete list")) { db in
try RemindersList.delete(remindersList)
.execute(db)
}
Expand Down
74 changes: 62 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(LocalizedStringKey("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(LocalizedStringKey("Reorder lists")) { db in
var ids = remindersLists.map(\.remindersList.id)
ids.move(fromOffsets: source, toOffset: destination)
try RemindersList
Expand All @@ -143,12 +148,55 @@ class RemindersListsModel {

#if DEBUG
func seedDatabaseButtonTapped() {
withErrorReporting {
try database.seedSampleData()
Task {
await withErrorReporting {
if let undoManager {
try await undoManager.freeze()
do {
try database.seedSampleData()
try await undoManager.unfreeze()
} catch {
try await undoManager.unfreeze()
throw error
}
} else {
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 +360,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 +385,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
14 changes: 13 additions & 1 deletion Examples/Reminders/Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,20 @@ struct ReminderText: FTS5 {
}

extension DependencyValues {
mutating func bootstrapDatabase(syncEngineDelegate: (any SyncEngineDelegate)? = nil) throws {
mutating func bootstrapDatabase(
syncEngineDelegate: (any SyncEngineDelegate)? = nil,
undoManagerDelegate: (any UndoManagerDelegate)? = nil
) throws {
defaultDatabase = try Reminders.appDatabase()
defaultUndoManager = try UndoManager(
for: defaultDatabase,
tables: RemindersList.self,
RemindersListAsset.self,
Reminder.self,
Tag.self,
ReminderTag.self,
delegate: undoManagerDelegate
)
defaultSyncEngine = try SyncEngine(
for: defaultDatabase,
tables: RemindersList.self,
Expand Down
Loading