Skip to content

Commit

Permalink
Merge pull request #9 from udacity/Exercise-SwiftUI-Tasks
Browse files Browse the repository at this point in the history
Exercise: SwiftUI Tasks
  • Loading branch information
Guerrix authored May 17, 2024
2 parents 808833e + da54b9a commit 1f9f1d5
Show file tree
Hide file tree
Showing 48 changed files with 3,195 additions and 0 deletions.
Binary file not shown.
28 changes: 28 additions & 0 deletions lesson-2-concurrency-in-iOS-apps/exercises/swiftUI-tasks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
### Exercise: SwiftUI Tasks

## Overview

This exercise is a continuation of the TripJournal app. You will refactor the app to use SwiftUI's `Task` modifier for managing asynchronous operations, ensuring that tasks are properly managed and canceled when no longer needed.

#### Objectives
- Use the `Task` modifier to manage asynchronous operations.
- Cancel tasks when they are no longer needed.
- Ensure state changes and UI updates are properly handled.

#### Instructions

1. **Starter:**
- Navigate to the starter folder and open `TripJournal.xcodeproj`.
- Your task is to fill in the missing code as indicated by comments in the Swift files (`AuthView.swift`, `TripList.swift`, `TripForm.swift`).

2. **Solution:**
- After you complete the exercise, or if you need to check your work, you can find the complete code in the solution folder.
- Compare your solution with the completed code to understand different approaches or to debug any issues you encountered.

#### Setup
- Ensure you have the latest version of Xcode installed on your Mac.
- Open the Xcode project from the `starter` folder to begin the exercise.

Feel free to reach out if you encounter any difficulties or have questions regarding the exercises.

Happy coding!

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import SwiftUI

@main
struct TripJournalApp: App {
var body: some Scene {
WindowGroup {
RootView(service: JournalServiceLive())
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"colors" : [
{
"color" : {
"platform" : "universal",
"reference" : "systemPurpleColor"
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "original"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import SwiftUI

struct AuthView: View {
/// Describes validation errors that might occur locally in the form.
struct ValidationError: LocalizedError {
var errorDescription: String?

static let emptyUsername = Self(errorDescription: "Username is required.")
static let emptyPassword = Self(errorDescription: "Password is required.")
}

@State private var username: String = ""
@State private var password: String = ""
@State private var isLoading = false
@State private var error: Error?
@Environment(\.journalService) private var journalService

@State private var task: Task<Void, Never>? = nil

// MARK: - Body

var body: some View {
Form {
Section(
content: inputs,
header: header,
footer: buttons
)
}
.loadingOverlay(isLoading)
.alert(error: $error)
.task {
await checkTokenExpiration()
}
.onDisappear {
task?.cancel()
}
}

// MARK: - Views

private func header() -> some View {
Image(.authHeader)
.resizable()
.frame(width: 100, height: 100)
.clipShape(RoundedRectangle(cornerRadius: 25))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 30)
.padding(.bottom, 70)
}

@ViewBuilder
private func inputs() -> some View {
TextField("Username", text: $username)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.username)
SecureField("Password", text: $password)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.password)
}

private func buttons() -> some View {
VStack(alignment: .center, spacing: 10) {
Button(
action: {
task = Task {
await logIn()
}
},
label: {
Text("Log In")
.frame(maxWidth: .infinity, alignment: .center)
}
)
.buttonBorderShape(.capsule)
.buttonStyle(.borderedProminent)

Button(
action: {
task = Task {
await register()
}
},
label: {
Text("Create Account")
.frame(maxWidth: .infinity, alignment: .center)
}
)
.buttonBorderShape(.capsule)
.buttonStyle(.bordered)
}
.padding()
}

// MARK: - Networking

private func validateForm() throws {
if username.nonEmpty == nil {
throw ValidationError.emptyUsername
}
if password.nonEmpty == nil {
throw ValidationError.emptyPassword
}
}

private func logIn() async {
isLoading = true
do {
try validateForm()
try await journalService.logIn(username: username, password: password)
} catch {
self.error = error
}
isLoading = false
}

private func register() async {
isLoading = true
do {
try validateForm()
try await journalService.register(username: username, password: password)
} catch {
self.error = error
}
isLoading = false
}

private func checkTokenExpiration() async {
DispatchQueue.main.async {
guard journalService.tokenExpired else {
return
}
error = SessionError.expired
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// KeychainHelper.swift
// TripJournal
//
// Created by Jesus Guerra on 5/17/24.
//

import Foundation
import Security

class KeychainHelper {
static let shared = KeychainHelper()
private let serviceName = "com.TripJournal.service"
private let accountName = "authToken"

private init() {}

func saveToken(_ token: Token) throws {
let tokenData = try JSONEncoder().encode(token)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: accountName,
kSecValueData as String: tokenData,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
]

SecItemDelete(query as CFDictionary) // Delete any existing item
let status = SecItemAdd(query as CFDictionary, nil)

guard status == errSecSuccess else {
throw KeychainError.unableToSaveToken
}
}

func getToken() throws -> Token? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: accountName,
kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne
]

var dataTypeRef: AnyObject? = nil
let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)

guard status == errSecSuccess else {
return nil
}

guard let data = dataTypeRef as? Data else {
return nil
}

return try JSONDecoder().decode(Token.self, from: data)
}

func deleteToken() throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: accountName
]

let status = SecItemDelete(query as CFDictionary)

guard status == errSecSuccess else {
throw KeychainError.unableToDeleteToken
}
}

enum KeychainError: Error {
case unableToSaveToken
case unableToDeleteToken
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import SwiftUI

private struct JournalServiceKey: EnvironmentKey {
static var defaultValue: JournalService = JournalServiceLive()
}

extension EnvironmentValues {
/// A service that can used to interact with the trip journal API.
var journalService: JournalService {
get { self[JournalServiceKey.self] }
set { self[JournalServiceKey.self] = newValue }
}
}
Loading

0 comments on commit 1f9f1d5

Please sign in to comment.