◼︎ 개발 환경
- HW : MacBook Pro (14-inch, 2021)
- CPU : Apple M1 Pro
- MENORY : 16GB
- DISK : 512 GB SSD
- OS : macOS 15.0.1 (24A348)
- TOOLS : XCode 16.1
- Programming Language : Shift
◼︎ 프로그램 요구사항
- 배경 이미지와 명언이 프로그램을 클릭하면 변경되는 간단한 오늘의 명언 프로그램.
- 간단한 날씨정보도 같이 보여줌.
어떻게 프로그램을 만들 것인가 알아보기
- Unsplash API 키 준비
- XCode 을 설치
- 새로운 App 프로젝트를 생성
- 예제 코드
🅰 명언을 API에서 랜덤하게 가져오려면 다양한 명언 API를 사용할 수 있습니다. 예를 들어, Quotes REST API나 ZenQuotes API를 사용하면 간편하게 랜덤 명언을 가져올 수 있습니다. 여기서는 ZenQuotes API를 사용하여 Swift 코드에 적용하는 방법을 설명드리겠습니다.
요약 후 앞의 코드에 명언을 ZenQuotes API 사용하여 가져오는 수정된 코드를 제시한다. (참고로 ZenQuotes API 는 API Key 없이 사용할 수 있다.)
프로젝트 생성
그림1. 새로운 프로젝트 생성 |
그림2. 템플릿 목록에서 App 선택 |
그림4. 생성된 프로젝트 파일 구조 |
Unsplash API 사용을 위해서는 API Key 가 필요하다. API Key 는 Unsplash 홈페이지 에서 회원 가입 후 Unsplash Developer 페이지로 이동해서 Your Apps 섹션에서 New Application 버튼을 클릭하여 새로운 앱을 만드는 것으로 키를 생성할 수 있다.
"오늘의 명언" 프로그램 코딩
빌드하고 실행하면 통신 오류 처럼 보여지는 에러를 확인 할 수 있는데 이 부분을 해결하는 것에 큰 어려움이 있었다.
🆀 App Sandbox 에서 Connections 등의 세부 권한을 추가 하려면
"오늘의 명언" 프로그램 개선하기
- 무료 버전의 Unsplash API 호출은 시간당 50개의 요청과 하루에 5,000개의 요청으로 제약이 있다. 이런 이유에서 이미지를 가져오지 못하는 경우 미리 가지고 있는 이미지를 랜덤으로 보여주는 기능이 구현.
- 다음으로 단순하게 텍스트를 보여주는 것이 아니라 타이핑과 같은 효과 구현
- 외부에서 이미지를 가져오는 Unsplash API 을 호출하려면 키가 필요한데 소스에 코딩하여 사용하던 것을 입력할 수 있도록 프로그램 환경설정에 메뉴를 추가
- 우측 상단에 위치정보를 기반으로 테스트로 위치와 날씨 노출 구현
- Unsplash 의 경우 좌측 하단에 인스타그램과 같이 제작자 정보 노출 구현
private func fetchRandomLocalImage() {
let imageNames = (1...8).map { "\($0)" }
guard let randomImageName = imageNames.randomElement(), let nsImage = NSImage(named: randomImageName) else {
print("Error: Could not load local image.")
self.image = nil
return
}
self.isUnsplashImage = false // Unsplash 이미지가 아님을 설정
self.image = nsImage
self.fetchRandomQuote()
}
struct TypewriterText: View {
var text: String
var onComplete: (() -> Void)? = nil // 타입 효과 완료 시 호출되는 클로저
@State private var displayedText: String = ""
@State private var currentIndex: Int = 0
@State private var typingTimer: Timer? = nil
private let typingSpeed = 0.07
var body: some View {
Text(displayedText)
.onAppear {
startTyping()
}
.onChange(of: text) {
resetTyping()
startTyping()
}
}
private func startTyping() {
typingTimer?.invalidate()
typingTimer = nil
displayedText = ""
currentIndex = 0
typingTimer = Timer.scheduledTimer(withTimeInterval: typingSpeed, repeats: true) { timer in
if currentIndex < text.count {
let index = text.index(text.startIndex, offsetBy: currentIndex)
displayedText.append(text[index])
currentIndex += 1
} else {
timer.invalidate()
typingTimer = nil
onComplete?() // 타이핑이 완료되면 onComplete 클로저 호출
}
}
}
private func resetTyping() {
typingTimer?.invalidate()
typingTimer = nil
displayedText = ""
currentIndex = 0
}
}
import SwiftUI
struct SettingsView: View {
@AppStorage("UnsplashAPIKey") private var unsplashApiKey: String = ""
@AppStorage("OpenWeatherMapAPIKey") private var weatherApiKey: String = ""
@AppStorage("OpenAIAPIKey") private var openAIAPIKey: String = ""
@State private var showSaveConfirmation = false
var body: some View {
VStack(spacing: 20) {
// API Key Table
VStack(alignment: .leading, spacing: 15) {
HStack {
Text("Unsplash API Key")
.frame(width: 150, alignment: .leading)
TextField("Enter Unsplash API Key", text: $unsplashApiKey)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(width: 300)
}
HStack {
Text("OpenWeatherMap API Key")
.frame(width: 150, alignment: .leading)
TextField("Enter OpenWeatherMap API Key", text: $weatherApiKey)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(width: 300)
}
HStack {
Text("OpenAI API Key")
.frame(width: 150, alignment: .leading)
TextField("Enter OpenAI API Key", text: $openAIAPIKey)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(width: 300)
}
}
.padding(.horizontal, 20)
.padding(.top, 40)
Spacer()
// Save Button with Clear Background
Button(action: {
showSaveConfirmation = true
}) {
Text("Save Changes")
.font(.headline)
.foregroundColor(.blue) // Text color only, no background color
.padding()
.frame(maxWidth: .infinity)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.blue, lineWidth: 2)
)
}
.buttonStyle(PlainButtonStyle()) // Ensures the button has no additional background styling
.padding([.leading, .trailing, .bottom], 20)
.alert(isPresented: $showSaveConfirmation) {
Alert(title: Text("Settings Saved"), message: Text("Your API keys have been saved."), dismissButton: .default(Text("OK")))
}
}
.frame(width: 500, height: 250)
}
}
LocationManager
를 통해 사용자의 위치를 가져오고, OpenWeatherMap API를 사용하여 날씨 정보를 표시합니다.struct WeatherView: View {
@Binding var locationString: String?
let weatherInfo: String
var body: some View {
VStack(alignment: .trailing) {
Text(weatherInfo)
.font(.subheadline)
.foregroundColor(.white)
.padding()
.background(Color.black.opacity(0.4))
.cornerRadius(8)
.padding(.trailing, 20)
}
.padding(.top, 40)
}
}
class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
@Published var userLocation: CLLocationCoordinate2D?
@Published var locationString: String?
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBest
}
func requestLocation() {
manager.requestWhenInUseAuthorization()
manager.requestLocation()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let location = locations.first {
userLocation = location.coordinate
locationString = "Lat: \(location.coordinate.latitude), Lon: \(location.coordinate.longitude)"
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("Location error: \(error.localizedDescription)")
}
}
private func fetchImageFromUnsplash() {
guard !unsplashApiKey.isEmpty, let url = URL(string: "https://api.unsplash.com/photos/random?client_id=\(unsplashApiKey)") else {
print("Error: Unsplash API Key is missing or invalid URL.")
fetchRandomLocalImage()
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
defer { isLoading = false }
if let error = error {
print("Error fetching image: \(error.localizedDescription)")
DispatchQueue.main.async {
self.fetchRandomLocalImage()
}
return
}
guard let data = data else {
print("Error: No data received.")
DispatchQueue.main.async {
self.fetchRandomLocalImage()
}
return
}
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let urls = json["urls"] as? [String: String],
let imageUrlString = urls["regular"],
let imageUrl = URL(string: imageUrlString),
let user = json["user"] as? [String: Any],
let authorName = user["name"] as? String,
let profileImageUrls = user["profile_image"] as? [String: String],
let profileImageUrlString = profileImageUrls["small"],
let profileImageUrl = URL(string: profileImageUrlString),
let description = json["description"] as? String {
self.imageTitle = description
self.authorName = authorName
self.isUnsplashImage = true // Unsplash 이미지임을 설정
DispatchQueue.main.async {
self.downloadImage(from: imageUrl)
self.downloadAuthorProfileImage(from: profileImageUrl)
}
} else {
print("Error: Could not parse JSON.")
DispatchQueue.main.async {
self.fetchRandomLocalImage()
}
}
} catch {
print("Error parsing JSON: \(error.localizedDescription)")
DispatchQueue.main.async {
self.fetchRandomLocalImage()
}
}
}.resume()
}
private func downloadImage(from url: URL) {
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data, let nsImage = NSImage(data: data) else {
if let error = error {
print("Error downloading image: \(error.localizedDescription)")
} else {
print("Error: Unable to create image from data.")
}
DispatchQueue.main.async {
self.fetchRandomLocalImage()
}
return
}
DispatchQueue.main.async {
self.image = nsImage
self.fetchRandomQuote() // 이미지가 로드된 후에 명언을 가져옴
}
}.resume()
}
private func downloadAuthorProfileImage(from url: URL) {
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data, let nsImage = NSImage(data: data) else {
print("Error downloading author profile image: \(error?.localizedDescription ?? "Unknown error")")
return
}
DispatchQueue.main.async {
self.authorProfileImage = nsImage
}
}.resume()
}
func translateQuoteToKorean(_ quote: String) {
guard let url = URL(string: "https://api.openai.com/v1/chat/completions") else {
print("Error: Invalid URL for OpenAI API")
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("Bearer \(openAIAPIKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let prompt = "Translate this quote to Korean: \(quote)"
let parameters: [String: Any] = [
"model": "gpt-4o-mini",
"messages": [
["role": "user", "content": prompt ]
],
"max_tokens": 60
]
do {
request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
} catch {
print("Error: Failed to serialize JSON for request body - \(error)")
return
}
print("Sending request to ChatGPT API with prompt: \(prompt)")
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
print("Error: Network request failed - \(error.localizedDescription)")
return
}
if let httpResponse = response as? HTTPURLResponse {
print("Received HTTP response: \(httpResponse.statusCode)")
}
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 429 {
print("Quota exceeded. Please check your API plan and usage.")
// 사용자에게 할당량 초과 메시지를 표시하는 로직 추가
//return
}
guard let data = data else {
print("Error: No data received from ChatGPT API")
return
}
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
print("Received JSON response: \(json)")
if let choices = json["choices"] as? [[String: Any]],
let message = choices.first?["message"] as? [String: Any],
let translatedText = message["content"] as? String {
DispatchQueue.main.async {
self.translatedQuote = translatedText.trimmingCharacters(in: .whitespacesAndNewlines)
}
} else {
print("Error: Unexpected JSON structure or missing 'choices' key")
}
}
} catch {
print("Error: Failed to parse JSON response - \(error)")
}
}.resume()
}
import SwiftUI
struct SettingsView: View {
@AppStorage("UnsplashAPIKey") private var unsplashApiKey: String = ""
@AppStorage("OpenWeatherMapAPIKey") private var weatherApiKey: String = ""
@AppStorage("OpenAIAPIKey") private var openAIAPIKey: String = ""
@State private var showSaveConfirmation = false
var body: some View {
VStack(spacing: 20) {
// API Key Table
VStack(alignment: .leading, spacing: 15) {
HStack {
Text("Unsplash API Key")
.frame(width: 150, alignment: .leading)
TextField("Enter Unsplash API Key", text: $unsplashApiKey)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(width: 300)
}
HStack {
Text("OpenWeatherMap API Key")
.frame(width: 150, alignment: .leading)
TextField("Enter OpenWeatherMap API Key", text: $weatherApiKey)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(width: 300)
}
HStack {
Text("OpenAI API Key")
.frame(width: 150, alignment: .leading)
TextField("Enter OpenAI API Key", text: $openAIAPIKey)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(width: 300)
}
}
.padding(.horizontal, 20)
.padding(.top, 40)
Spacer()
// Save Button with Clear Background
Button(action: {
showSaveConfirmation = true
}) {
Text("Save Changes")
.font(.headline)
.foregroundColor(.blue) // Text color only, no background color
.padding()
.frame(maxWidth: .infinity)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.blue, lineWidth: 2)
)
}
.buttonStyle(PlainButtonStyle()) // Ensures the button has no additional background styling
.padding([.leading, .trailing, .bottom], 20)
.alert(isPresented: $showSaveConfirmation) {
Alert(title: Text("Settings Saved"), message: Text("Your API keys have been saved."), dismissButton: .default(Text("OK")))
}
}
.frame(width: 500, height: 250)
}
}