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