Chapters

Hide chapters

macOS by Tutorials

First Edition · macOS 12 · Swift 5.5 · Xcode 13

Section I: Your First App: On This Day

Section 1: 6 chapters
Show chapters Hide chapters

9. Adding Your Own Tasks
Written by Sarah Reichelt

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

In the previous two chapters, you created a menu bar app, used a custom view to display menu items and added a timer to control the app.

Then, you looked at different ways to communicate with the user using alerts and notifications.

The last stage of this app is giving users the ability to enter their own tasks, saving and reloading them as needed.

You’ll learn how to manage data storage, understand more about the Mac sandbox and see how to use a SwiftUI view in an AppKit app. Finally, you’ll give users the option to have the app launch whenever they log in to their Mac.

Storing Data

Before you can let users edit their tasks, you need to have a way of storing and retrieving them.

Open your project from the previous chapter or open the starter project for this chapter in the downloaded materials. The starter has no extra code, but it has the source files organized into groups in the Project navigator. This makes it easier to navigate around a large project as you collapse the groups you’re not working on right now.

Groups in starter project
Groups in starter project

Open the assets folder in the downloaded materials and drag DataStore.swift into the Models group. Check Copy items if needed and the Time-ato target, then click Finish to add the file to your project.

This file contains three methods:

  1. dataFileURL() returns an optional URL to the data storage file. Right now, this returns nil, but you’re going to fix that shortly.
  2. readTasks() uses the data storage URL to read the stored JSON and decode it into an array of Task objects, returning an empty array if anything goes wrong.
  3. save(tasks:) encodes the supplied Task objects into JSON and saves them to the data file.

The readTasks() and save(tasks:) methods are the same as you’d use in an iOS app, so there’s no need to go into the details. But dataFileURL() is going to be interesting.

Finding the Data File

In order to save the data, you first need to work out where to save it. Replace return nil in dataFileURL() with:

// 1
let fileManager = FileManager.default

// 2
do {
  // 3
  let docsFolder = try fileManager.url(
    for: .documentDirectory,
    in: .userDomainMask,
    appropriateFor: nil,
    create: true)
  // 4
  let dataURL = docsFolder
    .appendingPathComponent("Timeato_Tasks.json")
  return dataURL
} catch {
  // 5
  print("URL error: \(error.localizedDescription)")
  return nil
}
let dataStore = DataStore()
dataStore.save(tasks: tasks)

The Mac Sandbox

When you run an app, on either macOS or iOS, the operating system protects itself and all your other apps and data, by keeping your app inside its own sandbox. You saw how this blocked all downloads by default in Chapter 2, “Working With Windows”. Now, you’re running into the way that it protects your files.

cd ~/Library/Containers
ls -l
Listing containers in Terminal
Junvecl pokqaeduqs un Vuvroxin

App container
Umj feqbuoxiz

Retrieving Data

You saved the sample tasks to a file and confirmed where it actually is. Now, you’ll use that file to list the tasks when the app starts instead of using the sample tasks.

var tasks: [Task]
tasks = dataStore.readTasks()
Editing the data file.
Igogunj jco xeci qula.

Menu displaying data from file.
Liku yudqjuxuny hami kpib nune.

Opening the Sandbox

This app works perfectly inside the sandbox, without any extra permissions, but not all apps are the same. There are some settings you can change if you have an app that needs more capabilities.

Sandbox settings
Mosmlat xidhesvf

Editing the Tasks

You’ve got the file handling working and tested. Now, it’s time to allow your users to edit their own tasks. To do this, you’ll use a SwiftUI view that you’ll display in a new window whenever the user selects the Edit Tasks… menu item.

@State private var dataStore = DataStore()
@State private var tasks: [Task] = []
// 1
VStack {
  // 2
  ForEach($tasks) { $task in
    HStack(spacing: 20) {
      // 3
      TextField(
        "",
        text: $task.title,
        prompt: Text("Task title"))
        .textFieldStyle(.squareBorder)
      
      // 4
      Image(systemName: task.status == .complete
        ? "checkmark.square"
        : "square")
        .font(.title2)

      // 5
      Button {
        // delete task here
      } label: {
        Image(systemName: "trash")
      }
    }
  }
  // 6
  .padding(.top, 12)
  .padding(.horizontal)

  // buttons go here
}
// 7
.frame(minWidth: 400, minHeight: 430)

Showing the Data

Right now, this view has nothing to show, so add these methods to EditTasksView:

func getTaskList() {
  // 1
  tasks = dataStore.readTasks()
  // 2
  addEmptyTasks()
}

func addEmptyTasks() {
  // 3
  while tasks.count < 10 {
    tasks.append(Task(id: UUID(), title: ""))
  }
}
.onAppear {
  getTaskList()
}
Task editor preview
Cehc ilesob nwaxooy

Deleting Tasks

So far, so good. The new view looks great. Now, you must make the delete buttons work. Add this new method beneath the others:

func deleteTask(id: UUID) {
  // 1
  let taskIndex = tasks.firstIndex {
    $0.id == id
  }

  // 2
  if let taskIndex = taskIndex {
    // 3
    tasks.remove(at: taskIndex)
    // 4
    addEmptyTasks()
  }
}
deleteTask(id: task.id)
Task editor live preview
Dakw acugiw wusu nziqaom

Adding the Buttons

Next, you need to add three control buttons:

Spacer()

HStack {
}
Extract subview
Ecvduff sakdaiv

// 1
HStack {
  // 2
  Button("Cancel", role: .cancel) {
    // close window
  }
  // 3
  .keyboardShortcut(.cancelAction)

  // 4
  Spacer()

  // 5
  Button("Mark All Incomplete") {
    // mark tasks as incomplete
  }

  Spacer()

  Button("Save") {
    // save tasks & close window
  }
}
// 6
.padding(12)
Preview with buttons
Yqikieh basw zatgasv

.frame(width: 400, height: 430)

Coding the Buttons

The last task for this view is to make these new buttons do their jobs.

func closeWindow() {
  NSApp.keyWindow?.close()
}
closeWindow()
@Binding var tasks: [Task]
let dataStore: DataStore
EditButtonsView(tasks: $tasks, dataStore: dataStore)
func saveTasks() {
  // 1
  tasks = tasks.filter {
    !$0.title.isEmpty
  }
  // 2
  dataStore.save(tasks: tasks)
  // 3
  closeWindow()
}

func markAllTasksIncomplete() {
  // 4
  for index in 0 ..< tasks.count {
    tasks[index].reset()
  }
}
markAllTasksIncomplete()
saveTasks()

Showing the Edit Window

Open AppDelegate.swift and find the @IBAction called showEditTasksWindow(_:). You’ve already connected this to the Edit Tasks… menu item.

// 1
let hostingController = NSHostingController(
  rootView: EditTasksView())
  
// 2
let window = NSWindow(contentViewController: hostingController)
window.title = "Edit Tasks"
// 3
let controller = NSWindowController(window: window)

// 4
NSApp.activate(ignoringOtherApps: true)
// 5
controller.showWindow(nil)
import SwiftUI
Task editor window
Cozs apivos xabvok

Saving and Reloading

Your interface is looking good, so now it’s time to run some tests and check that everything is working as expected. Open the Edit Tasks window and then press Escape. The window closes as expected.

dataStore.save(tasks: tasks)
Task editor showing completed task.
Hizz ifidiq jlawixp niyhsudej cupv.

// 1
let activeTaskIndex = tasks.firstIndex {
  $0.status == .inProgress
}
if let activeTaskIndex = activeTaskIndex {
  // 2
  timerState = .runningTask(taskIndex: activeTaskIndex)
}
Edited tasks
Uweror vuklm

Edited tasks in menu.
Uduhaz qingv ab zacu.

Using Notification Center

To solve this, you’ll send a notification whenever you save the data. This isn’t a visible notification like you used in the last chapter. Instead, you’ll use the NotificationCenter to post an NSNotification. Any object can register an observer to listen for this notification and react to it.

extension Notification.Name {
  static let dataRefreshNeeded =
    Notification.Name("dataRefreshNeeded")
}
NotificationCenter.default.post(
  name: .dataRefreshNeeded,
  object: nil)
var refreshNeededSub: AnyCancellable?
// 1
refreshNeededSub = NotificationCenter.default
  // 2
  .publisher(for: .dataRefreshNeeded)
  // 3
  .sink { _ in
    // 4
    self.tasks = self.dataStore.readTasks()
  }
Edits appearing in menu.
Uhilm oqgaotoky oy kowa.

Launching on Login

A utility app like this is the kind of app people want to have running all the time, and that means you need to add a way for the app to start when the user logs in.

Adding a Swift Package

You’ll use the Swift Package Manager to include this package in your app. If you’ve used SwiftPM in an iOS app, then this is a familiar process.

https://github.com/sindresorhus/LaunchAtLogin
Searching for the package.
Yoedvpahx kif jfa kuggaja.

Adding the package.
Esvult lra jaydicu.

Package dependencies
Powzaxu pupejjipzaip

Adding a build phase.
Amfedx i giejj jloyo.

"${BUILT_PRODUCTS_DIR}/LaunchAtLogin_LaunchAtLogin.bundle/Contents/Resources/copy-helper-swiftpm.sh"
Run script phase
Rix mpvawn lxapi

Using the New Library

To implement this feature, you need to be able to tell whether the user has enabled launch on login so that you can show a checkmark in its menu item. And, the menu item has to be able to toggle the setting.

import LaunchAtLogin
launchOnLoginMenuItem.state = LaunchAtLogin.isEnabled ? .on : .off
LaunchAtLogin.isEnabled.toggle()
Launch on Login enabled
Boupcd ig Yiwil ekarpit

App launched after login
Ejb kiurrlaz obfap vixot

Troubleshooting

If the app didn’t launch on login, it may be due to having too many old builds of the app on your hard drive. Deleting Xcode’s derived data will most likely fix it.

rm -rf ~/Library/Developer/Xcode/DerivedData
Resolve package versions
Fonohzo tedkomi nefwuurs

Using the App

You’re probably now thinking of using the app in your day-to-day work. In the final section of this book, you’ll learn about distributing your app, but for now, you’re going to get it into your Applications folder so you can run it more conveniently.

enum TaskTimes {
  // #if DEBUG
  //  // in debug mode, shorten all the times to make testing faster
  //  static let taskTime: TimeInterval = 2 * 60
  //  static let shortBreakTime: TimeInterval = 1 * 60
  //  static let longBreakTime: TimeInterval = 3 * 60
  // #else
  static let taskTime: TimeInterval = 25 * 60
  static let shortBreakTime: TimeInterval = 5 * 60
  static let longBreakTime: TimeInterval = 30 * 60
  // #endif
}
print(Bundle.main.bundlePath)
Printing the bundle path.
Qbusburb rru kubcfu hebc.

Challenges

Challenge: The About Box

If you select About Time-ato from the menu, the About box opens, but it’s in the background, so you may not be able to see it. In other parts of this app, you’ve seen how to bring the app to the front before showing alerts or opening new windows.

NSApp.orderFrontStandardAboutPanel(nil)

Key Points

  • macOS apps operate inside a sandbox. This keeps their data and settings inside a container folder.
  • Storing and retrieving data from files uses this container and not your main user folders.
  • There are ways to open the sandbox if your app requires, or you can disable it if you don’t plan to distribute through the App Store.
  • AppKit apps can contain SwiftUI views.
  • NotificationCenter provides a mechanism for publishing information throughout the app.
  • Launching a Mac app on login can be tricky, especially for a sandboxed app.
  • The Swift Package Manager works in a Mac app exactly the same as it does in an iOS app.

Where to Go From Here?

You’ve reached the end of this section and of this app. You can probably think of lots of improvements to make to it, so go for it. Make it into the app that you want to use.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now