SpacePod 37 PodList Refactor

SpacePod 37 PodList Refactor

The changes depicted in 36 result in a failure to load on first launch. The following changes to PodListView fix the issue, and begin the work of decluttering our PodListView.

YouTube

Goals

  1. Fix first launch bug introduced last time
  2. Move CoreData related functions to Persistence

Fix Bug

PodListView

.task {
    if pods.isEmpty { await getOld() }
}
private func getOld() async {
    let to = pods.last?.date?.previous(1) ?? Date()

Clean Up

  1. Cut and paste delete and save into Persistence.
  2. prepend viewContext with container.
func delete(_ pod: Pod) {
    container.viewContext.delete(pod)
    save()
}

func save() {
    do {
        try container.viewContext.save()
    } catch {
        let nsError = error as NSError
        fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
    }
}
  1. save() to PersistenceController.shared.save()
  2. delete() to PersistenceController.shared.delete(pod)

SpacePod 36 URLSession GET New & More

SpacePod 36 URLSession GET New & More

So far we’ve just been showing pods from today to December 1, 2021. Let’s update our app to…

  1. GET newer pods (than what’s in the DB)
  2. GET older pods when the user requests (taps for more)

YouTube

Steps

  1. Date n days ago
  2. GET pods from Date to Date
  3. Extract saving to save()
  4. add delete(_ pod: Pod)
  5. where to get the from date?
  6. Animation

Step 1 Date+Extensions

extension Date {
    /// Returns the date n days ago
    func previous(_ days: Int) -> Date {
        return Calendar.current.date(byAdding: DateComponents(day: -days), to: self) ?? self
    }
}

Step 2 Network

Next we’ll modify our getPods function to take from and to Date parameters.

...
unc getPods(_ from: Date, _ to: Date) async -> [Pod]? {
    let start = "&start_date=" + from.yyyy_MM_dd
    let end = "&end_date=" + to.yyyy_MM_dd
    guard let url = URL(string: "\(url.api)\(apiKey)\(start)\(end)\(thumbs)") else { return nil }
...

Step 3…

3 Save

private func save() {
    do {
        try viewContext.save()
    } catch {
        let nsError = error as NSError
        fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
    }
}

4 Delete

In order to test getting the newest dates we either need to come back tomorrow or add a swipe to delete function that only works in debugging mode.

private func delete(_ pod: Pod) {
    viewContext.delete(pod)
    save()
}
.swipeActions {
    #if DEBUG
    Button {
        viewContext.delete(pod)
        save()
    } label: {
        Label("Delete", systemImage: "trash")
    }
    .tint(.red)
    #endif
}
...
Section {
    Button("GET New") { Task { await getNew() } }
    Button("GET Old") { Task { await getOld() } }
}

getNew

First let’s get any pods that are newer than whats in our CoreData store. If the newest pod is the same date as today don’t do anything.

/// GET pods from newest to today
private func getNew() async {
    guard let from = pods.first?.date else { return }
    let to = Date()
    let compare = Calendar.current.compare(from, to: to, toGranularity: .day)
    if compare == .orderedAscending {
        if await Network().getPods(from, Date()) != nil {
            save()
        }
    }
}

getOld

Then we’ll get the pods for the next 30 days from the oldest pod in our store.

/// GET pods from oldest to 30 days from oldest
private func getOld() async {
    guard let to = pods.last?.date?.previous(1) else { return }
    let from = to.previous(30)
    if await Network().getPods(from, to) != nil {
        save()
    }
}

Animation

Lastly let’s add the default animation to our fetch request so make the experience a bit less jarring.

@FetchRequest(entity: Pod.entity(),
              sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)],
              predicate: nil,
              animation: .default)
var pods: FetchedResults<Pod>

Potential Issues

  1. Invalid dates get an error back from the API (e.g. 2021-02-29)

Bug Fix

The changes depicted in the video result in a failure to load on first launch. The following changes to PodListView fix the issue.

PodListView

.task {
    if pods.isEmpty { await getOld() }
}
private func getOld() async {
    let to = pods.last?.date?.previous(1) ?? Date()

SpacePod 35 SwiftUI Web Links

SpacePod 35 SwiftUI Web Links

The api doesn’t fully support all the features of the site. Let’s provide a Safari link in the details view of each pod.

YouTube

DateFormatter+Extensions.swift

  1. Delete formatter.timeZone = TimeZone(secondsFromGMT: 0) (bug fix)
  2. New Struct url in Network
  3. Add yyMMdd formatter to DateFormatter+Extensions
  4. Add yyMMdd to Date+Extensions
  5. Add webUrl to Date+Extensions
  6. Create WebLinkView
  7. Add WebLinkView to PodDetailView

1, 2 DateFormatter+Extensions

/// Formats date as "yyMMdd" (e.g. 210102)
static let yyMMdd: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateFormat = "yyMMdd"
    formatter.calendar = Calendar(identifier: .iso8601)
    return formatter
}()

3, 4 Date+Extensions

/// Returns a date string as "yyMMdd" (e.g. 210102)
var yyMMdd: String {
    return DateFormatter.yyMMdd.string(from: self)
}

/// Returns the web page url for date (e.g. https://apod.nasa.gov/apod/ap220102.html)
var webUrl: URL? {
    return URL(string: "https://apod.nasa.gov/apod/ap" + self.yyMMdd + ".html")
}

5 WebLink.swift

import SwiftUI

/// Links to the web page for the given Pod or nil
struct WebLink: View {
    var date: Date?

    var body: some View {
        if let url = date?.webUrl {
            Link(destination: url) {
                Label("Open in Safari", systemImage: "safari.fill")
                    .symbolRenderingMode(.hierarchical)
            }
        }
    }
}

struct WebLink_Previews: PreviewProvider {
    static var previews: some View {
        List {
            Section(header: Text("Today")) { WebLink(date: Date()) }
            Section(header: Text("NIL")) { WebLink() }
        }
    }
}

6 PodDetailView.swift

WebLink(date: pod.date)

SpacePod 34 CoreData Unique Attributes

SpacePod 34 CoreData Unique Attributes

Let’s ensure that all database entries are unique.

YouTube

Steps

  1. Add a constraint and change it to date
  2. Add container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy to Persistence init

Persistence.swift

...
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
...

SpacePod 33 CoreData Save & Fetch

SpacePod 33 CoreData Save & Fetch

We’ve done the ground work in the last few videos, and we’re finally ready to save to and fetch from our CoreData store.

YouTube

Steps

  1. Add .environment and specify \.managedObjectContext keypath
  2. Add @Environment var viewContext to PodListView
  3. Create @FetchRequest and sort by date descending
  4. Save context on getPods completion
  5. Fix previews

SpacePodApp.swift

...
    ContentView()
        .environment(\.managedObjectContext, PersistenceController.shared.container.viewContext)
...

PodListView

//@State var pods: [Pod] = []
    @Environment(\.managedObjectContext) private var viewContext

    @FetchRequest(
        entity: Pod.entity(),
        sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)]
    ) var pods: FetchedResults<Pod>

...

private func getPods() async {
        if await Network().getPods() != nil {
            do {
                try viewContext.save()
            } catch {
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }
...
PodListView()
    .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)

Resources

Core Data Lab Donny Wals on Core Data Sarunw on What is @Environment in SwiftUI

SpacePod 32 CoreData Decodable NSManagedObject

SpacePod 32 CoreData Decodable NSManagedObject

Previously we added a CoreData model named Nasa. Let’s rename it to Pod to mirror our existing object, and then work through the resulting build errors so that our app is using the new object.

YouTube

Goals

  1. Mirror CoreData Model and Pod Model
  2. Update Pod to work with CoreData
  3. Update our views to ensure everything still works as before

Steps

  1. Rename Nasa to Pod
  2. Change Codegen to Category/Extension, and
  3. Set module to current and save
  4. import CoreData
  5. NSManagedObject
  6. Delete properties in Pod
  7. Change Codble to Decodable
  8. Remove id
  9. add DecoderConfigurationError
  10. add CodingUserInfoKey
  11. add convenience
  12. add context
  13. add self.init
  14. add decoder.userInfo[CodingUserInfoKey.managedObjectContext] = PersistenceController.shared.container.viewContext
  15. add if let to optionals in views

Steps

1, 2, 3

<entity name="Pod" representedClassName=".Pod" syncable="YES" codeGenerationType="category">

4 to 13, Pod.swift Complete

import Foundation
import CoreData

class Pod: NSManagedObject, Decodable {

    private enum CodingKeys: String, CodingKey {
        case copyright
        case date
        case explanation
        case hdurl
        case mediaType
        case serviceVersion
        case title
        case url
        case thumbnailUrl
    }

    enum DecoderConfigurationError: Error {
        case missingManagedObjectContext
    }

    required convenience init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        guard let context = decoder.userInfo[CodingUserInfoKey.managedObjectText] as? NSManagedObjectContext else {
            throw DecoderConfigurationError.missingManagedObjectContext
        }

        self.init(context: context)

        copyright = try container.decodeIfPresent(String.self, forKey: .copyright)
        date = try container.decodeIfPresent(Date.self, forKey: .date)
        explanation = try container.decode(String.self, forKey: .explanation)
        hdurl = try container.decodeIfPresent(URL.self, forKey: .hdurl)
        mediaType = try container.decode(String.self, forKey: .mediaType)
        serviceVersion = try container.decode(String.self, forKey: .serviceVersion)
        title = try container.decode(String.self, forKey: .title)
        url = try container.decodeIfPresent(URL.self, forKey: .url)
        thumbnailUrl = try container.decodeIfPresent(URL.self, forKey: .thumbnailUrl)
    }
}

extension CodingUserInfoKey {
    static let managedObjectText = CodingUserInfoKey(rawValue: "managedObjectContext")!
}

14 Data+Extensions.swift

decoder.userInfo[CodingUserInfoKey.managedObjectContext] = PersistenceController.shared.container.viewContext

15 Optionals

Handle optionals in our views with if let... (for now)

SpacePod 18 Decoding & Formatting Dates

Goals

  1. Decode date as a Date?
  2. Format date nicely like this March 26, 2007
  3. Sort and Query by date
  4. Eventually cache data and images in CoreData

Resources

YouTube GitHub

Steps

  1. Date Formatters
  2. Date Decoding Strategy

DateFormatter+Extensions.swift

extension DateFormatter {

    /// Formats date as "yyyy-MM-dd" (e.g. 2021-12-18)
    static let yyyyMMdd: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd"
        formatter.calendar = Calendar(identifier: .iso8601)
        formatter.timeZone = TimeZone(secondsFromGMT: 0)
        return formatter
    }()

    /// Formats date as "Month Day, Year" (e.g. December 18, 2021)
    static let longDate: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .long
        formatter.timeStyle = .none
        formatter.calendar = Calendar(identifier: .iso8601)
        formatter.timeZone = TimeZone(secondsFromGMT: 0)
        return formatter
    }()
}

DateDecodingStrategy+Extensions

// add DateDecodingStrategy+ Extensions.swift
extension JSONDecoder.DateDecodingStrategy {
    /// Decodes Date in as yyyy-MM-dd (e.g. 2021-12-18)
    static var yyyyMMdd: JSONDecoder.DateDecodingStrategy {
        return JSONDecoder.DateDecodingStrategy.formatted(DateFormatter.yyyyMMdd)
    }
}

Data+Extensions

var toPod: Pod? {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .yyyyMMdd
        return try? decoder.decode(Pod?.self, from: self)
    }

    var toPods: [Pod]? {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .yyyyMMdd
        return try? decoder.decode([Pod]?.self, from: self)
    }

Pod.swift

Change the date property from type String to Date?

let date: Date?

and then modify default

date: DateFormatter.yyyyMMdd.date(from: "yyyy-MM-dd"),

PodDetailView.swift

Label(pod.date?.description ?? "", systemImage: "calendar")

Data+Extensions

var toPods: [Pod]? {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .yyyyMMdd
        return try? decoder.decode([Pod]?.self, from: self)
    }

Date+Extensions.swift

extension Date {
    /// Returns a date string as "Month Day, Year" (e.g. December 18, 2021)
    var long: String {
        return DateFormatter.longDate.string(from: self)
    }
}

PodDetailView.swift

Label(pod.date?.long ?? "", systemImage: "calendar")

macOS Commands - Homebrew

Homebrew

“The Missing Package Manager for macOS.”

While Homebrew isn’t strictly necessary, it does make installing and maintaining command line apps a breeze.

Home Page Github

Installation

It’s always a good idea to read an install script before installing.

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

Basics

brew update to update Homebrew. brew upgrade to upgrade packages and formulas.

Documentation

manpage

SpacePod 17: Adding and Ignoring App Secrets

SpacePod 17 - App Secrets

Thoughts

Lot’s of ways to do this, and all of them have drawbacks. We are doing it this way to keep things simple. Warning, this approach is simple, but the api key will in plaintext in the app bundle!!!

Other Methods

  1. Environment setting
  2. CI/CD
  3. Shared iCloud database
  4. Bypass the API altogether

Objectives

  1. Use your api key instead of the demo key
  2. Make sure we don’t check our api key into the repo

Steps

  1. Update Network.swift
  2. Create .gitignore
  3. Copy & paste from github
  4. Add Secrets.json to .gitignore?
  5. Add Secrets.json to project and target
  6. Create Secrets.swift model
  7. Load apiKey from Secrets.txt ?? DEMO_KEY

Secret.swift

  1. New Group “Models”
  2. New Type Secret
import Foundation

struct Secret: Codable {
    let apiKey: String
}

Decoding

var toSecret: Secret? {
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    return try? decoder.decode(Secret.self, from: self)
}

Update Network

Delete getString() & getPod()

import Foundation

class Network {

    let baseUrl = "https://api.nasa.gov/planetary/apod"
    let apiKey = "?api_key=" + (File.data(from: "Secrets", withExtension: .json)?.toSecret?.apiKey.description ?? "DEMO_KEY")

    func getPods() async -> [Pod]? {
        let url = URL(string: "\(baseUrl)\(apiKey)&count=20")!
        do {
            let request = URLRequest(url: url)
#if DEBUG
            print("🌎 request: " + request.debugDescription)
#endif
            let (data, response) = try await URLSession.shared.data(for: request)
#if DEBUG
            print("🌎 response: " + response.debugDescription)
#endif
            return data.toPods
        }
        catch {
#if DEBUG
            print("🌎 error: " + error.localizedDescription)
#endif
            return nil
        }
    }
}

Create .gitignore

  1. Add a new empty file at the root of the project and name it “.gitignore”. Be sure to notinclude it in our target.
  2. Copy and paste the contents of Swift.gitignore into .gitignore.
  3. Add Secrets.json to .gitignore
# Project
Secrets.json

Add Secrets.json

  1. Select the project
  2. Right click to create a new file named Secrets.json
  3. Be sure to include it with SpacePod target
  4. Add the following json
  5. Be sure to change apiKey to your api key from https://api.nasa.gov and save
{
	"api_key" : "PASTE_YOUR_API_KEY_HERE"
}

SpacePod 16b - Bug Fixes

Last time we accidently introduced a bug where the selected pod details can be overwritten by our default pod.

Objectives

  1. Fix Pod.default bug
  2. Fix Preview
  3. Fix Task reloading bug

Steps

Replace default

In PodDetailView.swift replace = Pod.default with : Pod.

Fix Preview

Load and decode get-pod.json, and pass it to our view.

struct PodDetailView_Previews: PreviewProvider {
    static var pod = File.data(from: "get-pod", withExtension: .json)?.toPod
    static var previews: some View {
        PodDetailView(pod: pod!)
    }
}

Delete Task

Delete task as we are passing the data from PodListView.swift.

	.task {
            if let response = await Network().getPod() {
                pod = response
            }
        }

PodDetailView.swift

import SwiftUI

struct PodListView: View {
    @State var pods: [Pod] = []

    var body: some View {
        NavigationView {
            List {
                ForEach(pods, id: \.self) { pod in
                    NavigationLink(destination: PodDetailView(pod: pod)) {
                        Text(pod.title)
                    }
                }
            }
            .navigationTitle(pods.isEmpty ? "Fetching Pods..." : "SpacePod")
            .task {
                if pods.isEmpty { await getPods() }
            }
            .refreshable {
                await getPods()
            }
        }
    }

    private func getPods() async {
        if let response = await Network().getPods() {
            pods = response
        }
    }
}

struct PodListView_Previews: PreviewProvider {
    static var pods = File.data(from: "get-pods", withExtension: .json)?.toPods
    static var previews: some View {
        PodListView(pods: pods!)
    }
}

Recently picked up: Courage Is Calling: Fortune Favors the Brave on a whim at Commonplace Books, and so far so good. 📚

SpacePod 01: Project Overview

Let’s build a SwiftUI app that displays the NASA photo of the day. Updates will be tiny and on YouTube and GitHub

import SwiftUI

struct ContentView: View {
    var body: some View {
        List {
            MyButton(title: "Button 1")
            MyButton(title: "Button 2")
        }
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
struct MyButton: View {
    
    var title: String
    
    @State private var showSheet = false
    
    var body: some View {
        Button(action: {
            showSheet.toggle()
        }, label: {
            Text(title)
        }).sheet(isPresented: $showSheet, content: {
            Text("Sheet for \(title)")
        })
    }
}

Privacy Policy

Philosophy

  1. You have a fundamental right to privacy.
  2. Your data is yours (not ours).

Policies

  1. We will only collect personally identifiable data with your consent
  2. We will retain information only for as long as needed
  3. We will not share information with 3rd parties (other than Apple, RevenueCat, and TelemetryDeck) unless required by law or it is critical to the functionality of the site or app
  4. We do not knowingly collect data about children
  5. This site will be updated as our policies evolve

Collected Data

We use two different 3rd party tools. One handles the purchase process and the other handles basic anonymized usage statistics for kerntronics.com and our apps.

Purchases

We use RevenueCat for in app purchases

  1. Purchases are tracked analytics and app functionality
  2. Purchases are linked to the user’s identity
  3. See RevenueCat’s App Privacy Page for more information

Analytics

We use TelemetryDeck to collect anonymized usage data on this website and our apps

  1. See TelemetryDeck’s Privacy Policy for more information