SpacePod 30 GET by Date

Let’s update our networking code to GET Pods by date, and then display them from newest to oldest.

YouTube

Date+Extensions

/// Returns a date string as "yyyy-MM-dd" (e.g. 2021-12-30)
var yyyy_MM_dd: String {
    return DateFormatter.yyyyMMdd.string(from: self)
}

Network

let count = "&count=" + "20"
    let thumbs = "&thumbs=" + "true"
    let start = "&start_date=" + "2021-12-01"
    let end = "&end_date=" + Date().yyyy_MM_dd
...
    let url = URL(string: "\(baseUrl)\(apiKey)\(start)\(end)\(thumbs)")!

PodListView

  1. Temporarily Infnite Scrolling
  2. pods = pods.uniqued().reversed()

SpacePod 31 Required Init

We’re still making some behind the scenes changes so that we can cache api responses using CoreData. Today we’ll change Pod from a struct to a class.

YouTube

  1. Change Pod form a struct to a class
  2. Remove Hashable conformance
  3. Add Identifiable conformance
  4. Add id property
  5. Add id case
  6. Init id = UUID()
  7. Identify by \.id instead of \.self
  8. remove .uniqued from List
  9. Add required init
let id: UUID
...
case id
...
id = UUID()
ForEach(pods, id: \.id) { pod in
required init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)

    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)
    url = try container.decodeIfPresent(URL.self, forKey: .url)
    thumbnailUrl = try container.decodeIfPresent(URL.self, forKey: .thumbnailUrl)
    title = try container.decode(String.self, forKey: .title)
    serviceVersion = try container.decode(String.self, forKey: .serviceVersion)
    mediaType = try container.decode(String.self, forKey: .mediaType)
}

SpacePod 29 CodingKeys

We’re still making some behind the scenes changes so that we can cache api responses using CoreData. Today we’ll restore our CodingKeys from our git repo in preparation for making our model conform to NSManagedObject.

YouTube

  1. Open Pod.swift
  2. Click Enable Code Review
  3. Copy & Paste CodingKeys from the right pane to the left
  4. Add case thumbnailUrl
private enum CodingKeys: String, CodingKey {
    case copyright
    case date
    case explanation
    case hdurl
    case mediaType
    case serviceVersion
    case title
    case url
    case thumbnailUrl
    }

SpacePod 28 CoreData Persistence Controller

Over the next few videos we’ll be doing some behind the scenes work that shouldn’t have any effect on the user experience until we’re finished. Let’s add a persistence controller, and then confirm the app still works as expected.

YouTube

Steps

  1. Select Network.swift in the Navigator
  2. New Group from Selection Controllers
  3. Create a New Project named CoreDataPersistence
  4. Check use Core Data
  5. Option Drag Persistence.swift to Controllers in SpacePod
  6. Remove new item loop
  7. change Item to NASA
  8. Change container name to Model
import CoreData

struct PersistenceController {
    static let shared = PersistenceController()

    static var preview: PersistenceController = {
        let result = PersistenceController(inMemory: true)
        let viewContext = result.container.viewContext
//            let newItem = NASA(context: viewContext)
//            newItem.timestamp = Date()
        do {
            try viewContext.save()
        } catch {
            // Replace this implementation with code to handle the error appropriately.
            // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
            let nsError = error as NSError
            fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
        }
        return result
    }()

    let container: NSPersistentContainer

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "Model")
        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        container.viewContext.automaticallyMergesChangesFromParent = true
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.

                /*
                Typical reasons for an error here include:
                * The parent directory does not exist, cannot be created, or disallows writing.
                * The persistent store is not accessible, due to permissions or data protection when the device is locked.
                * The device is out of space.
                * The store could not be migrated to the current model version.
                Check the error message to determine what the actual problem was.
                */
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
    }
}

SpacePod 27 Creating a CoreData Model

So far we’ve just been hitting the API anytime we need to display some day. Let’s create a Core Data model that will let us cache the JSON payload.

YouTube

Problem

We’re hitting the API every time we need some data.

Steps

  1. Create a new file of type Data Model
  2. Add an Entity named NASA
  3. Add an attribute for each property from Pod.swift
  • date: Date?
  • url: URI?
  • hdurl: URI?
  • thumbnailUrl: URI?
  • title: String?
  • copyright: String?
  • explanation: String?
  • mediaType: String?
  • serviceVersion: String?

DIFF

<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="19574" systemVersion="21A559" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="NASA" representedClassName="NASA" syncable="YES" codeGenerationType="class">
    <attribute name="copyright" optional="YES" attributeType="String"/>
    <attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
    <attribute name="explanation" optional="YES" attributeType="String"/>
    <attribute name="hdurl" optional="YES" attributeType="URI"/>
    <attribute name="mediaType" optional="YES" attributeType="String"/>
    <attribute name="serviceVersion" optional="YES" attributeType="String"/>
    <attribute name="thumbnailUrl" optional="YES" attributeType="URI"/>
    <attribute name="title" optional="YES" attributeType="String"/>
    <attribute name="url" optional="YES" attributeType="URI"/>
</entity>
</model>

SpacePod 26 AccentColor

Let’s make our app stand out with custom accent colors, and verify that it works nicely in light, dark, and high contrast mode.

YouTube

Deprecated Way

This method still works, but has been deprecated. Let’s define the AccentColor in Assets instead.

ContentView()
    .accentColor(Color.purple)

New Way

Steps

  1. In the Xcode Navigator…
  2. Select Assets
  3. Select AccentColor

We can set custom colors for different devices and display modes, but let’s use a system color for now. Using a provided system color means the OS handles all the different display modes for us.

  1. Select Universal
  2. Set the universal color to systemPurpleColor

Resources

HIG: Visual Design Color .systemPurple

Diff

 {
   "colors" : [
     {
+      "color" : {
+        "platform" : "universal",
+        "reference" : "systemPurpleColor"
+      },
       "idiom" : "universal"
     }
   ],

SpacePod 25 Multi-Tasking

Currently we aren’t supporting multi-tasking. Let’s add it now.

YouTube

Goal

  1. Enable multitasking

Steps

  1. Select SpacePod project in the navigator
  2. Select SpacePod target
  3. Enable all Device Orientations Portrait Upside Down Landscape Left Landscape Right

SpacePod 24 Infinite SwiftUI List

We’re only showing 20 Pods at a time. Let add infinite scrolling.

YouTube

Goals

  1. Add infinite scrolling
  2. Ensure pods are unique

Steps

  1. Append to pods instead of replacing it
  2. Animate the change
  3. getPods() when last row appears
  4. Ensure pods are unique

1, 2 Append and Animate

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

3 getPods() when last row appears

NavigationLink(destination: PodDetailView(pod: pod)) {
    Text(pod.title)
}
.task {
    if pod == pods.last {
        await getPods()
    }
}

4 Ensure pods are unique

Adapted from SwiftLee.

// from https://www.avanderlee.com/swift/unique-values-removing-duplicates-array/
public extension Array where Element: Hashable {
    func uniqued() -> [Element] {
        var seen = Set<Element>()
        return filter{ seen.insert($0).inserted }
    }
}

SpacePod 23 Obfuscated Logging

SpacePod 23 Obfuscated Logging

Whenever we run our app it debug mode it prints every network request and response. The request and response contain the apiKey. It’s only a matter of time until I accidentally leak it during a screencast. Let’s fix that right now by replacing all occurrences of our apiKey with something else.

YouTube

Problem

  1. Our apiKey get printed to the console whenever we make a request.

Goals

  1. Obfuscate the key before we print it so that I don’t accidentally show it on screen.

Steps

  1. Move our print statement inside a private function.
  2. Ensure we only print in production.
  3. Replace our api key with a slug.
  4. Clean up our existing print statements.

1, 2, 3

    private func log(_ string: String) {
#if DEBUG
        print(string.replacingOccurrences(of: apiKey, with: "?api_key=" + "YOUR_OBFUSCATED_API_KEY"))
#endif
    }

4

log("🌎 request: " + request.debugDescription)
log("🌎 response: " + response.debugDescription)
log("🌎 error: " + error.localizedDescription)

Network.swift Complete

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")
    let count = "&count=20"
    let thumbs = "&thumbs=true"

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

    private func log(_ string: String) {
#if DEBUG
        print(string.replacingOccurrences(of: apiKey, with: "?api_key=" + "YOUR_OBFUSCATED_API_KEY"))
#endif
    }
}

SpacePod 22 - SwiftUI Previews - Device, DisplayName, & InterfaceOrientation

SpacePod 22 - SwiftUI Previews - Device, DisplayName, & InterfaceOrientation

Previously we added a “Fetching Pods…” view whenever we don’t have any pods to display. This works well enough on iPads, but results in a blank screen on iPhones. Let’s fix the problem and add some additional previews along the way.

Problem

Fetching pods doesn’t show on iPhones in portrait mode

YouTube

Goals

  1. Show “Fetching Pods” on iPhone and iPads in any orientation
  2. Use .previewDevice()
  3. Use .previewDisplayName()
  4. Use .previewInterfaceOrientation()

Steps

  1. Create a preview with empty pods
  2. Add a second Text("Fetching Pods...")
  3. Attach task to NavigationView
  4. Move list inside if let pod = pods.first {
  5. Add previewDevice
  6. Add previewDisplayName
  7. Add previewInterfaceOrientation
  8. Create two iPad Layouts

View

NavigationView {
    
}
.task {
    if pods.isEmpty { await getPods() }
}

List

if let pod = pods.first {
    List {
        ...

“Fetching Pods…”

} else {
    Text("Fetching Pods...")
    Text("Fetching Pods...")
}

Previews

PodListView(pods: [])
    .previewDevice(PreviewDevice(rawValue: "iPhone 13 mini"))
    .previewDisplayName("iPhone 13 mini - Fetching Pods...")
PodListView(pods: pods!)
    .previewDevice(PreviewDevice(rawValue: "iPhone 13 mini"))
    .previewDisplayName("iPhone 13 mini - Fetching Pods...")
PodListView(pods: [])
    .previewDevice(PreviewDevice(rawValue: "iPad mini (6th generation)"))
    .previewDisplayName("iPad mini - Fetching Pods...")
    .previewInterfaceOrientation(.landscapeRight)
PodListView(pods: pods!)
    .previewDevice(PreviewDevice(rawValue: "iPad mini (6th generation)"))
    .previewDisplayName("iPad mini")
    .previewInterfaceOrientation(.landscapeRight)

PodListView.swift Complete

import SwiftUI

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

    var body: some View {
        NavigationView {

            if let pod = pods.first {
                List {
                    ForEach(pods, id: \.self) { pod in
                        NavigationLink(destination: PodDetailView(pod: pod)) {
                            Text(pod.title)
                        }
                    }
                }
                .navigationTitle("SpacePod")
                .navigationBarTitleDisplayMode(.inline)

                .refreshable {
                    await getPods()
                }
                PodDetailView(pod: pod)

            } else {
                Text("Fetching Pods...")
                Text("Fetching Pods...")
            }
        }
        .task {
            if pods.isEmpty { 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 {

        // Disable some of these if your previews are timing out.

        PodListView(pods: [])
            .previewDevice(PreviewDevice(rawValue: "iPhone 13 mini"))
            .previewDisplayName("iPhone 13 mini - Fetching Pods...")
        PodListView(pods: pods!)
            .previewDevice(PreviewDevice(rawValue: "iPhone 13 mini"))
            .previewDisplayName("iPhone 13 mini")

        PodListView(pods: [])
            .previewDevice(PreviewDevice(rawValue: "iPad mini (6th generation)"))
            .previewDisplayName("iPad mini - Fetching Pods...")
            .previewInterfaceOrientation(.landscapeRight)
        PodListView(pods: pods!)
            .previewDevice(PreviewDevice(rawValue:  "iPad mini (6th generation)"))
            .previewDisplayName("iPhone 13 mini")
            .previewInterfaceOrientation(.landscapeRight)
    }
}

SpacePod 21 SwiftUI ‘Split View’

SpacePod 21 SwiftUI ‘Split View’

YouTube

Issues

  1. Large empty area when app launches
  2. Navigation title layout issues

Steps

  1. Detail View (do not put in in an HStack)
  2. Loading View
  3. NavigationView tweaks

PodListView.swift Changes

Loading…

Text("Fetching Pods...")

Display First Pod

if let pod = pods.first {
        PodDetailView(pod: pod)
} else {
        Text("Fetching Pods...")
}
List {
...
    .navigationTitle("SpacePod")
    .navigationBarTitleDisplayMode(.inline)

PodListView.swift Complete

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("SpacePod")
            .navigationBarTitleDisplayMode(.inline)
            .task {
                if pods.isEmpty { await getPods() }
            }
            .refreshable {
                await getPods()
            }

            if let pod = pods.first {
                PodDetailView(pod: pod)
            } else {
                Text("Fetching Pods...")
            }

        }
    }

    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!)
    }
}

SpacePod 20 Video Thumbnails

SpacePod 20 Video Thumbnails

YouTube

Problem

Some PODs don’t display an image. Sometimes there’s nothing to show, but typically it means the “Picture Of the Day” is something else like a video. Fortunately The API can optionally return a thumbnail image. Unfortunately we have to do a little work to know when to use what.

  1. Update Network to return video thumbnails
  2. Display image at thumbnailUrl if available
  3. Otherwise display image at url
  4. Update preview to display both

Network.swift

let count = "&count=20"
    let thumbs = "&thumbs=true"

    func getPods() async -> [Pod]? {
        let url = URL(string: "\(baseUrl)\(apiKey)\(count)\(thumbs)")!

PodDetailView.swift

struct PodDetailView: View {
    @State var pod: Pod
    var body: some View {
        List {
            if let url = pod.thumbnailUrl ?? pod.url {
                PodImageView(url: url)
            }
            Text(pod.title)
                .font(.title)
                .bold()
                .padding(.vertical)
            if let copyright = pod.copyright {
                Label(copyright, systemImage: "c.circle.fill")
            }
            if let date = pod.date {
                Label(date.long, systemImage: "calendar")
            }
            Text(pod.explanation)
                .padding(.vertical)
        }
    }
}

PodImageView.swift

struct PodDetailView_Previews: PreviewProvider {
    static var imagePod = File.data(from: "get-pod", withExtension: .json)?.toPod?.url
    static var videoPod = File.data(from: "get-video", withExtension: .json)?.toPod?.thumbnailUrl
    static var previews: some View {
        List {
            Section("image") {
                PodImageView(url: imagePod!)
            }
            Section("video") {
                PodImageView(url: videoPod!)
            }
        }
    }
}

SpacePod 19: AsyncImagePhase, Resizing, and Scaling

SpacePod 19: AsyncImagePhase, Resizing, and Scaling

YouTube

Problems

  1. Images don’t cover the entire width of the cell on larger screens.
  2. No error is displayed if the image cannot be loaded.

PodImageView

import SwiftUI

struct PodImageView: View {
    var url: URL

    var body: some View {
        AsyncImage(url: url) { phase in
            switch phase {
            case .empty:
                ProgressView()
            case .success(let image):
                image
                    .resizable()
                    .scaledToFill()
                    .listRowInsets(.init())
                    .listRowSeparator(.hidden)
            case .failure:
                Label {
                    Text("Error fetching image")
                } icon: {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .foregroundColor(.yellow)
                }
            @unknown default:
                EmptyView()
            }
        }
    }
}

struct PodImageView_Previews: PreviewProvider {
    static var imageUrl = File.data(from: "get-pod", withExtension: .json)?.toPod?.url
    static var previews: some View {
        List {
            PodImageView(url: imageUrl!)
        }
    }
}

PodDetailView

if let url = pod.url { PodImageView(url: url) }

ImageRowView.swift

struct ImageRowView: View {
    var image: Image
    var body: some View {
        image
            .resizable()
            .scaledToFill()
            .listRowInsets(.init())
            .listRowSeparator(.hidden)
    }
}

ErrorRowView.swift

struct ErrorRowView: View {
    var body: some View {
        Label {
            Text("Error fetching image")
        } icon: {
            Image(systemName: "exclamationmark.triangle.fill")
                .foregroundColor(.yellow)
        }
    }
}

Finally

Let’s use our new views to clean up our switch statement.

struct PodImageView: View {
    var url: URL
    var body: some View {
        AsyncImage(url: url) { phase in
            switch phase {
            case .empty:                ProgressView()
            case .success(let image):   ImageRowView(image: image)
            case .failure(_):           ErrorRowView()
            @unknown default:           EmptyView()
            }
        }
    }
}

SpacePod 18: AsyncImagePhase, Resizing, and Scaling

SpacePod 18 Decoding & Formatting Dates

YouTube

Goals

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

Steps

  1. Formatters
  2. 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")
```#  <#Title#>

SpacePod 17 - 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!!!

YouTube

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"
}

Notes on PlantUML

PlantUML

Installation

  1. You’ll want Homebrew
  2. brew install swiftplantuml
  3. brew install plantuml

Warning!!!

By default PlantUML uses a remote servce to generate images of .puml files. You can read more at plantuml.com/faq

Links to png or svg generated by PlantUML Server are valid forever (that is as long as the server is up). However, we do not store any diagrams on our servers. This may sound contradictory. It is not: the whole diagram is compressed into the URL itself. When the server receives the URL, it decompresses the URL to retrieve the diagram text and generates the image. There is no need to store anything. Even if the server is down, you can retrieve the diagram using the flag -decodeurl with the command line. Furthermore, the diagram data is stored in PNG metadata, so you can fetch it even from a downloaded image.


Make a Class Diagram

While this seems reasonable I want to generate the images locally. There makes for a two-step process:

  1. Generate .puml
  2. Generate .png

1. Generate .puml

swiftplantuml --output consoleOnly MyClass.swift > my-class.puml

2. Generate .puml

plantuml will crop the image if the size limit it set higher than needed.

plantuml -DPLANTUML_LIMIT_SIZE=14000 my-class.puml -png


References

PlantUML Homgepage

Excellent Blog Post

SwiftPlantUML


Getting Started

plantuml -help

SpacePod 44 Xcode Markdown Syntax Highlighting

If we place a hidden plist in the project directory Xcode will render it, but we won’t be able to edit the file. Instead, we’ll explicitly set the filetype on our markdown files so that Xcode knows how to highlight them.

Steps

  1. Select markdown files
  2. Open the inspector
  3. Change Type to Markdown Text

The active file won’t re-render unless it is closed and reopened. If that doesn’t work close and reopen the project.

SpacePod 43 CachedAsyncImage

SwiftUI’s AsyncImage cache policy isn’t adequate for our use, which means we’re hitting the network too much. Let’s write our own CachedAsyncImage view that will…

  1. Fetch images given a url
  2. Save images to the documents directory
  3. Load images from the documents directory
  4. Act as a drop in replacement for AsyncImage

Goals

  1. Dramatically decrease network traffic
  2. Local image storage for future features

Problems

There are a few issues with this implementation as is that we’ll address in later installments.

  1. Memory usage goes up with each image displayed

Steps

  1. Set minimum PodImageView height to 300
  2. Get documents directory as URL
  3. UIImage extension save as .jpg
  4. Create minimum CachedAsyncImage view
  5. GET image by URL
  6. Load image from documents directory
  7. Logic for CachedAsyncImage body
  8. Preview for CachedAsyncImage

Network

static let shared = Network()

CachedAsyncImage

struct CachedAsyncImage<Content>: View where Content: View {

    private let url: URL
    private let scale: CGFloat
    private let transaction: Transaction
    private let content: (AsyncImagePhase) -> Content

    @State private var image: UIImage?

    init(
        url: URL,
        scale: CGFloat = 1.0,
        transaction: Transaction = Transaction(),
        @ViewBuilder content: @escaping (AsyncImagePhase) -> Content
    ) {
        self.url = url
        self.scale = scale
        self.transaction = transaction
        self.content = content
    }

    var body: some View {
        if let cached = FileManager.loadImage(name: url.lastPathComponent) ?? image {
            content(.success(Image(uiImage: cached)))
                .animation(transaction.animation, value: cached)
        } else {
            content(.empty).task {
                image = await Network.shared.getImage(url: url)
                image?.saveJPG(name: url.lastPathComponent)
            }
        }
    }
}

8 CachedAsyncImage_Previews

struct CachedAsyncImage_Previews: PreviewProvider {

    static let url = URL(string: "https://apod.nasa.gov/apod/image/2112/JwstLaunch_Arianespace_1080.jpg")!

    static var previews: some View {
        CachedAsyncImage(url: url) { phase in
            switch phase {
            case .empty:
                ProgressView()
            case .success(let image):
                image
            case .failure(let error):
                ErrorView(description: error.localizedDescription)
            [@unknown](https://micro.blog/unknown) default:
                fatalError()
            }
        }
    }
}

SpacePod 41 URLSession GET Image

SwiftUI's AsyncImage cache policy isn't adequate for our use, which means we're hitting the network too much. Let's write our own CachedAsyncImage view that will...

  1. Fetch images given a url
  2. Save images to the documents directory
  3. Load images from the documents directory
  4. Act as a drop in replacement for AsyncImage

For now we'll just implement Step 1.

Goals

  1. Dramatically decrease network traffic
  2. Local image storage for future features

Steps

Network

private func getImage(url: URL) async -> UIImage? {
    do {
        let request = URLRequest(url: url)
        log("🌎 request: " + request.debugDescription)
        let (data, response) = try await URLSession.shared.data(for: request)
        log("🌎 response: " + response.debugDescription)
        return UIImage(data: data)
    }
    catch {
        log("🌎 error: " + error.localizedDescription)
        return nil
    }
}
func log(_ string: String) {
#if DEBUG
    print(string)
#endif
}

SpacePod 42 Save & Open Images

SwiftUI’s AsyncImage cache policy isn’t adequate for our use, which means we’re hitting the network too much. Let’s write our own CachedAsyncImage view that will…

  1. Fetch images given a url
  2. Save images to the documents directory
  3. Load images from the documents directory
  4. Act as a drop in replacement for AsyncImage

For now we’ll just implement Step 2 and 3.

You can watch the video here or check out the source code on github.

Goals

  1. Dramatically decrease network traffic
  2. Local image storage for future features

Steps

2a FileManager+Extension

extension FileManager {
    static var getDocumentsDirectory: URL {
        let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
        return paths[0]
    }
}

2b UIImage+Extension

extension UIImage {
    func saveJPG(named: String) {
        if let data = self.jpegData(compressionQuality: 1.0) {
            let filename = FileManager.getDocumentsDirectory.appendingPathComponent("\(named)")
            log("🗃 \(filename)")
            try? data.write(to: filename)
        }
    }
}

3 Load Image

extension FileManager {

    private func loadImage(name: String) -> UIImage? {
        let filename = FileManager.getDocumentsDirectory.appendingPathComponent("\(name)")
        let image = UIImage(contentsOfFile: filename.path)
        log("🗃 \(filename)")
        return image
    }
}

SpacePod 40 SwiftUI AsyncImage Auto-Reload on Cancel

Sometimes our image doesn’t load. I’m thinking it’s because…

  1. Height is unknown until .success
  2. Image above or below succeeds before image in question
  3. Request is cancelled

Goals

  1. Determine why it’s failing
  2. If cancelled fetch the image again
  3. Otherwise display the localized error description

YouTube

Steps

  1. Display the error description
  2. Reload image if it was cancelled

1 Display Error Description

ErrorView

struct ErrorView: View {
    var description: String
...
            Text(description)
...

2 Reload on Cancelled

PodImageView

case .failure(let error): ErrorView(description: error.localizedDescription)

We could have a button shows the error, but let’s just automatically fetch the image again instead.

case .failure(let error):
if error.localizedDescription == "cancelled" {
    PodImageView(url: url)
} else {
    ErrorView(description: error.localizedDescription)
}

Watch the YouTube series and checkout the github repo.

SpacePod 39 Side Bar

Let’s modify the UI by adding a Sidebar and showing images in our list.

Goals

  1. Improve navigation
  2. Beautify app
  3. Clarify navigation

YouTube

Steps

  1. Use Pod.recent request
  2. Add SidebarView
  3. Replace List Text with our PodDetailView
  4. Move app structure to ContentView
  5. Change List to Section in PodDetailView
  6. Switch on pods
  7. Split our list view into loadingView and listView
  8. Set the environment for our Content and SidebarView previews
struct SideBarView: View {
    var body: some View {
        List {
            NavigationLink {
                PodListView()
            } label: {
                Label("Recents", systemImage: "clock")
            }

        }
    }
}

ContentView

struct ContentView: View {
    var body: some View {
        NavigationView {
            SidebarView()
            PodListView()
        }
    }
}

PodDetailView

List to Section

PodListView

struct PodListView: View {

    @Environment(\.managedObjectContext) private var viewContext
    @FetchRequest(fetchRequest: Pod.recent, animation: .default)
    var pods: FetchedResults<Pod>

    var body: some View {

        switch pods {
        case nil: loadingView
        default:  listView
        }
    }

    var loadingView: some View {
        Text("Fetching Pods...")
            .task {
                if pods.isEmpty { await getMore() }
            }
    }

    var listView: some View {
        List {
            ForEach(pods, id: \.id) { pod in
                PodDetailView(pod: pod)
            }
            Section {
                Button("GET More") { Task { await getMore() } }
            }
        }
        .navigationBarTitleDisplayMode(.inline)
        .refreshable {
            await getNew()
        }
    }
}

Previews

Be sure to Set the environment value for our ContentView and SideBarView

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
    }
}
struct SidebarView_Previews: PreviewProvider {
    static var previews: some View {
        // Wrapping it with a NavigationView enables the expected behavior in Canvas
        NavigationView {
            SidebarView()
                .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
        }
    }
}

Watch the YouTube series and checkout the github repo.

SpacePod 38 Debugging with Console

We’ve got two warnings that have been bothering me for a while now. Let’s see what we can do to fix them.

YouTube

Goals

  1. Eliminate Warnings
  2. Use Console
  3. Use Breakpoints

Warnings to Fix

  1. CoreData Warning
  2. Broken AutoLayout

1 CoreData Warning

Console

SpacePod[1241:3102508] [error] warning:  View context accessed for persistent container Model with no stores loaded
CoreData: warning:  View context accessed for persistent container Model with no stores loaded

Troubleshooting

Reading the message I see our first clue. Xcode’s Core Data template code has the same warning, but only once. Let’s set a breakpoint to see when this warning is logged. We can see it’s happening when we set our merge policies.

The Fix

If we make our configuration changes after we load the persistent store the warning goes away.

-   container.viewContext.automaticallyMergesChangesFromParent = true
-   container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            ...
         })
+        container.viewContext.automaticallyMergesChangesFromParent = true
+        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
     }

Broken AutoLayout

Console

2022-01-06 18:36:07.689295-0600 SpacePod[1317:3105452] [LayoutConstraints] Unable to simultaneously satisfy constraints.
	Probably at least one of the constraints in the following list is one you don't want.
	Try this:
		(1) look at each constraint and try to figure out which you don't expect;
		(2) find the code that added the unwanted constraint or constraints and fix it.
(
    "<NSLayoutConstraint:0x60000326c1e0 'BIB_Trailing_CB_Leading' H:[_UIModernBarButton:0x140e239f0]-(6)-[_UIModernBarButton:0x140e1e280'SpacePod']   (active)>",
    "<NSLayoutConstraint:0x60000326c230 'CB_Trailing_Trailing' _UIModernBarButton:0x140e1e280'SpacePod'.trailing <= BackButton.trailing   (active, names: BackButton:0x140e17960 )>",
    "<NSLayoutConstraint:0x60000326d040 'UINav_static_button_horiz_position' _UIModernBarButton:0x140e239f0.leading == UILayoutGuide:0x60000280cd20'UIViewLayoutMarginsGuide'.leading   (active)>",
    "<NSLayoutConstraint:0x60000326d090 'UINavItemContentGuide-leading' H:[BackButton]-(6)-[UILayoutGuide:0x60000280c1c0'UINavigationBarItemContentLayoutGuide']   (active, names: BackButton:0x140e17960 )>",
    "<NSLayoutConstraint:0x60000323ef80 'UINavItemContentGuide-trailing' UILayoutGuide:0x60000280c1c0'UINavigationBarItemContentLayoutGuide'.trailing == _UINavigationBarContentView:0x140e1acc0.trailing   (active)>",
    "<NSLayoutConstraint:0x60000326f980 'UIView-Encapsulated-Layout-Width' _UINavigationBarContentView:0x140e1acc0.width == 0   (active)>",
    "<NSLayoutConstraint:0x60000323f340 'UIView-leftMargin-guide-constraint' H:|-(8)-[UILayoutGuide:0x60000280cd20'UIViewLayoutMarginsGuide'](LTR)   (active, names: '|':_UINavigationBarContentView:0x140e1acc0 )>"
)

Will attempt to recover by breaking constraint
<NSLayoutConstraint:0x60000326c1e0 'BIB_Trailing_CB_Leading' H:[_UIModernBarButton:0x140e239f0]-(6)-[_UIModernBarButton:0x140e1e280'SpacePod']   (active)>

Make a symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints to catch this in the debugger.
The methods in the UIConstraintBasedLayoutDebugging category on UIView listed in <UIKitCore/UIView.h> may also be helpful.
2022-01-06 18:36:07.689830-0600 SpacePod[1317:3105452] [LayoutConstraints] Unable to simultaneously satisfy constraints.
	Probably at least one of the constraints in the following list is one you don't want.
	Try this:
		(1) look at each constraint and try to figure out which you don't expect;
		(2) find the code that added the unwanted constraint or constraints and fix it.
(
    "<NSLayoutConstraint:0x60000326c190 'BIB_Leading_Leading' H:|-(0)-[_UIModernBarButton:0x140e239f0]   (active, names: BackButton:0x140e17960, '|':BackButton:0x140e17960 )>",
    "<NSLayoutConstraint:0x60000326d040 'UINav_static_button_horiz_position' _UIModernBarButton:0x140e239f0.leading == UILayoutGuide:0x60000280cd20'UIViewLayoutMarginsGuide'.leading   (active)>",
    "<NSLayoutConstraint:0x60000326d090 'UINavItemContentGuide-leading' H:[BackButton]-(6)-[UILayoutGuide:0x60000280c1c0'UINavigationBarItemContentLayoutGuide']   (active, names: BackButton:0x140e17960 )>",
    "<NSLayoutConstraint:0x60000323ef80 'UINavItemContentGuide-trailing' UILayoutGuide:0x60000280c1c0'UINavigationBarItemContentLayoutGuide'.trailing == _UINavigationBarContentView:0x140e1acc0.trailing   (active)>",
    "<NSLayoutConstraint:0x60000326f980 'UIView-Encapsulated-Layout-Width' _UINavigationBarContentView:0x140e1acc0.width == 0   (active)>",
    "<NSLayoutConstraint:0x60000323f340 'UIView-leftMargin-guide-constraint' H:|-(8)-[UILayoutGuide:0x60000280cd20'UIViewLayoutMarginsGuide'](LTR)   (active, names: '|':_UINavigationBarContentView:0x140e1acc0 )>"
)

Will attempt to recover by breaking constraint
<NSLayoutConstraint:0x60000326c190 'BIB_Leading_Leading' H:|-(0)-[_UIModernBarButton:0x140e239f0]   (active, names: BackButton:0x140e17960, '|':BackButton:0x140e17960 )>

Make a symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints to catch this in the debugger.
The methods in the UIConstraintBasedLayoutDebugging category on UIView listed in <UIKitCore/UIView.h> may also be helpful.
2022-01-06 18:36:07.704019-0600 SpacePod[1317:3105452] [LayoutConstraints] Unable to simultaneously satisfy constraints.
	Probably at least one of the constraints in the following list is one you don't want.
	Try this:
		(1) look at each constraint and try to figure out which you don't expect;
		(2) find the code that added the unwanted constraint or constraints and fix it.
(
    "<NSLayoutConstraint:0x600003268500 UIView:0x140e19890.trailing == _UIBackButtonMaskView:0x140e7cd30.trailing   (active)>",
    "<NSLayoutConstraint:0x60000326c7d0 'Mask_Trailing_Trailing' _UIBackButtonMaskView:0x140e7cd30.trailing == BackButton.trailing   (active, names: BackButton:0x140e17960 )>",
    "<NSLayoutConstraint:0x60000326c910 'MaskEV_Leading_BIB_Trailing' H:[_UIModernBarButton:0x140e239f0]-(0)-[UIView:0x140e19890]   (active)>",
    "<NSLayoutConstraint:0x60000326d040 'UINav_static_button_horiz_position' _UIModernBarButton:0x140e239f0.leading == UILayoutGuide:0x60000280cd20'UIViewLayoutMarginsGuide'.leading   (active)>",
    "<NSLayoutConstraint:0x60000326d090 'UINavItemContentGuide-leading' H:[BackButton]-(6)-[UILayoutGuide:0x60000280c1c0'UINavigationBarItemContentLayoutGuide']   (active, names: BackButton:0x140e17960 )>",
    "<NSLayoutConstraint:0x60000323ef80 'UINavItemContentGuide-trailing' UILayoutGuide:0x60000280c1c0'UINavigationBarItemContentLayoutGuide'.trailing == _UINavigationBarContentView:0x140e1acc0.trailing   (active)>",
    "<NSLayoutConstraint:0x60000326f980 'UIView-Encapsulated-Layout-Width' _UINavigationBarContentView:0x140e1acc0.width == 0   (active)>",
    "<NSLayoutConstraint:0x60000323f340 'UIView-leftMargin-guide-constraint' H:|-(8)-[UILayoutGuide:0x60000280cd20'UIViewLayoutMarginsGuide'](LTR)   (active, names: '|':_UINavigationBarContentView:0x140e1acc0 )>"
)

Will attempt to recover by breaking constraint
<NSLayoutConstraint:0x600003268500 UIView:0x140e19890.trailing == _UIBackButtonMaskView:0x140e7cd30.trailing   (active)>

Make a symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints to catch this in the debugger.
The methods in the UIConstraintBasedLayoutDebugging category on UIView listed in <UIKitCore/UIView.h> may also be helpful.

Troubleshooting

After reading through the conosle output it looks like the issue is with the navigation title. We’ll be adding a toolbar there fairly soon anyway, so let’s just remove the offending line for now.

The Workaround

-   .navigationTitle("SpacePod")
    .navigationBarTitleDisplayMode(.inline)

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()