Skip to content

๐ŸŒ™ ๋ผ๋ฒค๋” ํŒœํ•˜๋‹ˆ ํŒ€์˜ ํ–‰๋ณตํ•œ ๋‚ฎ์ž ์ž๊ธฐ Naptune

License

Notifications You must be signed in to change notification settings

Lavender-SH/Naptune-AppleDeveloperAcademy-POSTECH

ย 
ย 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

Naptune - ๋‚ฎ์ž ์„ ๊ณต์œ ํ•˜๋Š” ๊ณต๊ฐ„ ํ”„๋ผ์ด๋น— SNS


Naptune ํ”„๋กœ์ ํŠธ ์†Œ๊ฐœ

์•ฑ ์„ค๋ช…

  • Naptune ์•ฑ์€ ํ”ผ๋กœํ•œ ํ˜„๋Œ€์ธ๋“ค์„ ์œ„ํ•ด ์„ค๊ณ„๋œ ํ”„๋ผ์ด๋น— ์†Œ์…œ ์„œ๋น„์Šค๋กœ, ๋‚ฎ์ž  ์‹œ๊ฐ„์„ ์„ค์ •ํ•˜๊ณ  ์นœ๊ตฌ๋“ค๊ณผ ํ•จ๊ป˜ ๊ณต์œ ํ•˜๋ฉฐ ํ”ผ๋กœ๋ฅผ ํšŒ๋ณตํ•  ์ˆ˜ ์žˆ๋„๋ก ๋•์Šต๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๋Š” ๋‚ฎ์ž  ํƒ€์ด๋จธ์™€ ์•Œ๋ฆผ ๊ธฐ๋Šฅ์„ ํ†ตํ•ด ํšจ์œจ์ ์œผ๋กœ ํœด์‹์„ ์ทจํ•˜๊ณ , ๋‚ฎ์ž  ํ›„์—๋Š” ์นœ๊ตฌ๋“ค๊ณผ์˜ ํ”ผ๋“œ๋ฐฑ์„ ํ†ตํ•ด ๊ธ์ •์ ์ธ ๊ฒฝํ—˜์„ ๋งŒ๋“ค์–ด๊ฐˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. Naptune์€ ์ผ์ƒ์˜ ์—๋„ˆ์ง€๋ฅผ ๋˜์ฐพ๊ณ , ๋‚ฎ์ž  ๋ฌธํ™”๋ฅผ ์ƒˆ๋กญ๊ฒŒ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค.

์„ฑ๊ณผ

  • Apple Developer Academy @ POSTECH DELIGHTS-CON ์ตœ์ข… ๋ฐœํ‘œ

ํ”„๋กœ์ ํŠธ ๊ธฐ๊ฐ„

  • 2024.07.01 ~ 2024.08.13 (7์ฃผ) - Apple Developer Academy @ POSTECH (MC3 Challenge)

ํ”„๋กœ์ ํŠธ ์ฐธ์—ฌ ์ธ์›

  • iOS Developer 2๋ช…, Back-end 1๋ช…, Design 2๋ช…

๊ธฐ์ˆ  ์Šคํƒ

  • Framework SwiftUI, UIKit, WidgetKit, ActivityKit, CallKit, AVFoundation, PhotosUI, UserNotifications, Combine, Haptic, SnapKit, KDCircularProgress, AuthenticationService, FirebaseFireStore, FirebaseStorage

  • Design Pattern MVVM


Naptune์•ฑ์˜ ์ „์ฒด์ ์ธ ํ”Œ๋กœ์šฐ๋ฅผ ํ‘œํ˜„ํ•œ ๊ตฌ์„ฑ๋„



Naptune ํ•ต์‹ฌ ๊ธฐ๋Šฅ๊ณผ ์ฝ”๋“œ ์„ค๋ช…

  • 1. ๋‚ฎ์ž  ์‹œ๊ฐ„์„ ์„ค์ •ํ•˜๋Š” ํƒ€์ด๋จธ ๊ธฐ๋Šฅ
  • 2. ๋‚ฎ์ž  ๋ชจ๋“œ์— ์ง„์ž…ํ•˜๋ฉด ์นœ๊ตฌ์—๊ฒŒ ์•Œ๋ฆผ(Notification)์„ ๋ณด๋‚ด ๋‚ฎ์ž  ์ƒํƒœ๋ฅผ ๊ณต์œ ํ•˜๋Š” ๊ธฐ๋Šฅ
  • 3. ์ •ํ•ด์ง„ ๋‚ฎ์ž ์‹œ๊ฐ„์ด ์ข…๋ฃŒ๋˜๋ฉด CallKit์„ ํ†ตํ•ด ์•Œ๋ฆผ ์ „ํ™”๋ฅผ ์ œ๊ณตํ•˜๋Š” ๊ธฐ๋Šฅ
  • 4. ์ผ์–ด๋‚˜๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด ์ปค์Šคํ…€ ์นด๋ฉ”๋ผ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•˜๋Š” ๊ธฐ๋Šฅ
  • 5. ์นœ๊ตฌ๋“ค์˜ ๋‚ฎ์ž  ๊ธฐ๋ก๊ณผ ์ƒํƒœ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ํ”ผ๋“œ ๊ธฐ๋Šฅ
  • 6. ๋‹ค์ด๋‚˜๋ฏน ์•„์ผ๋žœ๋“œ์™€ ๋ผ์ด๋ธŒ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ๋ชจ๋“œ์—์„œ๋„ ๋‚จ์€ ๋‚ฎ์ž  ์‹œ๊ฐ„์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ํ™•์ธํ•˜๋Š” ๊ธฐ๋Šฅ

1. ๋‚ฎ์ž  ์‹œ๊ฐ„์„ ์„ค์ •ํ•˜๋Š” ํƒ€์ด๋จธ ๊ธฐ๋Šฅ

Naptune์˜ ๋‚ฎ์ž  ํƒ€์ด๋จธ ๊ธฐ๋Šฅ์€ ์‚ฌ์šฉ์ž๊ฐ€ ์›ํ•˜๋Š” ๋‚ฎ์ž  ์‹œ๊ฐ„์„ ์ง๊ด€์ ์œผ๋กœ ์„ค์ •ํ•˜๊ณ , ์„ค์ •๋œ ์‹œ๊ฐ„ ๋™์•ˆ ๋‚จ์€ ์‹œ๊ฐ„์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋„๋ก ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. ์ด ๊ธฐ๋Šฅ์€ ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒํ•œ ์Šฌ๋ผ์ด๋”์™€ ํ”„๋กœ๊ทธ๋ ˆ์Šค ๋ฐ”๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ๊ทน๋Œ€ํ™”ํ–ˆ์œผ๋ฉฐ, ์‹œ๊ฐ์  ํ”ผ๋“œ๋ฐฑ๊ณผ ํ–…ํ‹ฑ ํ”ผ๋“œ๋ฐฑ์„ ๊ฒฐํ•ฉํ•ด ํŽธ๋ฆฌํ•˜๊ณ  ๋ชฐ์ž…๊ฐ ์žˆ๋Š” ํƒ€์ด๋จธ ์„ค์ • ํ™˜๊ฒฝ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

ํƒ€์ด๋จธ User Flow

  1. ํƒ€์ด๋จธ ์„ค์ • ๋ฐ ์‹œ๊ฐ์  ํ”ผ๋“œ๋ฐฑ
  • ์Šฌ๋ผ์ด๋” ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ํ™œ์šฉํ•ด ์‚ฌ์šฉ์ž๊ฐ€ ๋“œ๋ž˜๊ทธ๋กœ ์‹œ์ž‘ ๋ฐ ์ข…๋ฃŒ ์‹œ๊ฐ„์„ ์„ค์ •
  • ํ˜„์žฌ ์„ค์ •๋œ ๋‚ฎ์ž  ์‹œ๊ฐ„์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๊ณ„์‚ฐํ•˜๊ณ  ํ™”๋ฉด์— ํ‘œ์‹œ
  • ํ–…ํ‹ฑ ํ”ผ๋“œ๋ฐฑ์„ ์ถ”๊ฐ€ํ•˜์—ฌ ์Šฌ๋ผ์ด๋” ์กฐ์ž‘ ์‹œ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜ ํ–ฅ์ƒ

  1. ์‹ค์‹œ๊ฐ„ ํ”„๋กœ๊ทธ๋ ˆ์Šค ๋ฐ”
  • KDCircularProgress ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋‚จ์€ ์‹œ๊ฐ„์„ ์‹œ๊ฐ์ ์œผ๋กœ ํ‘œ์‹œ
  • ์• ๋‹ˆ๋ฉ”์ด์…˜ ํšจ๊ณผ๋ฅผ ํ†ตํ•ด ์ง„ํ–‰๋ฅ ์ด ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์—…๋ฐ์ดํŠธ

  • SwiftUI๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์Šฌ๋ผ์ด๋”์™€ ํ”„๋กœ๊ทธ๋ ˆ์Šค ๋ฐ”๋ฅผ ๊ตฌ์„ฑํ•œ ์ฝ”๋“œ
@ViewBuilder
func sleepTimeSlider() -> some View {
    GeometryReader { proxy in
        let width = proxy.size.width
        ZStack {
            // ์Šฌ๋ผ์ด๋” ๋””์ž์ธ๊ณผ ์‹œ๊ณ„ ํ‘œ์‹œ
            ForEach(1...60, id: \.self) { index in
                Rectangle()
                    .fill(index % 5 == 0 ? .white : .gray)
                    .frame(width: 2, height: index % 5 == 0 ? 10 : 5)
                    .offset(y: (width - 60) / 2)
                    .rotationEffect(.init(degrees: Double(index) * 6))
            }
            // ๋‚จ์€ ์‹œ๊ฐ„์„ ๋‚˜ํƒ€๋‚ด๋Š” ํ”„๋กœ๊ทธ๋ ˆ์Šค ๋ฐ”
            KDCircularProgressView(progress: $progress)
                .frame(width: width - 40, height: width - 40)
        }
    }
}
  • ํƒ€์ด๋จธ ์‹œ์ž‘๊ณผ ์ข…๋ฃŒ ๊ด€๋ฆฌ
func startTimer() {
    timer?.invalidate()
    remainingSeconds = getTimeDifference() * 60
    timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
        if remainingSeconds > 0 {
            remainingSeconds -= 1
        } else {
            timer?.invalidate()
            timer = nil
            // ํƒ€์ด๋จธ ์ข…๋ฃŒ ํ›„ ํ–‰๋™
        }
    }
}
  • ๋‚จ์€ ์‹œ๊ฐ„ ์‹ค์‹œ๊ฐ„ ์—…๋ฐ์ดํŠธ
func startProgress() {
    let duration = Double(remainingSeconds)
    Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
        if self.remainingSeconds > 0 {
            self.progress = (1 - Double(self.remainingSeconds) / duration) * 360
        } else {
            timer.invalidate()
        }
    }
}
  • KDCircularProgress ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ ๋ฐฉ๋ฒ•
import SwiftUI
import KDCircularProgress

struct KDCircularProgressView: UIViewRepresentable {
    @Binding var progress: Double

    func makeUIView(context: Context) -> KDCircularProgress {
        let progressView = KDCircularProgress(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
        progressView.startAngle = -90
        progressView.progressThickness = 0.37
        progressView.trackThickness = 0.4
        progressView.clockwise = true
        progressView.gradientRotateSpeed = 1
        progressView.roundedCorners = true
        progressView.glowMode = .forward
        progressView.glowAmount = 0.8
        progressView.set(colors: UIColor(named: "ManboBlue400")!)
        
        progressView.trackColor = UIColor.gray.withAlphaComponent(0.3)
        return progressView
    }

    func updateUIView(_ uiView: KDCircularProgress, context: Context) {
        uiView.angle = progress + 5 // Convert percentage to degrees (100% = 360 degrees)
    }
}

2. ๋‚ฎ์ž  ๋ชจ๋“œ์— ์ง„์ž…ํ•˜๋ฉด ์นœ๊ตฌ์—๊ฒŒ ์•Œ๋ฆผ(Notification)์„ ๋ณด๋‚ด ๋‚ฎ์ž  ์ƒํƒœ๋ฅผ ๊ณต์œ ํ•˜๋Š” ๊ธฐ๋Šฅ

Naptune์€ ์‚ฌ์šฉ์ž๊ฐ€ ๋‚ฎ์ž  ๋ชจ๋“œ์— ์ง„์ž…ํ•˜๋ฉด ์นœ๊ตฌ๋“ค์—๊ฒŒ ํ‘ธ์‹œ ์•Œ๋ฆผ์„ ๋ณด๋‚ด ๋‚ฎ์ž  ์ƒํƒœ๋ฅผ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๊ณต์œ ํ•˜๋Š” ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์•Œ๋ฆผ์€ Firebase Cloud Messaging(FCM)์„ ํ†ตํ•ด ์ „๋‹ฌ๋˜๋ฉฐ, ์‚ฌ์šฉ์ž์™€ ์นœ๊ตฌ ๊ฐ„์˜ ์†Œ์…œ ์ธํ„ฐ๋ž™์…˜์„ ๊ฐ•ํ™”ํ•˜๋Š” ๋ฐ ์ค‘์ ์„ ๋‘์—ˆ์Šต๋‹ˆ๋‹ค. ์•Œ๋ฆผ์—๋Š” ๋‚ฎ์ž  ์‹œ์ž‘ ๋ฐ ์ข…๋ฃŒ ์‹œ๊ฐ„์ด ํฌํ•จ๋˜์–ด ์นœ๊ตฌ๊ฐ€ ์‚ฌ์šฉ์ž์—๊ฒŒ ์ „ํ™”๋‚˜ ๋ฉ”์‹œ์ง€๋กœ ์•Œ๋ฆผ์„ ๋ณด๋‚ผ ์ˆ˜ ์žˆ๋„๋ก ์œ ๋„ํ•ฉ๋‹ˆ๋‹ค.

KakaoTalk_Video_2025-01-19-09-32-20.mp4


  1. ํ‘ธ์‹œ ์•Œ๋ฆผ ์„ค์ • ๋ฐ ์ „์†ก
  • Firebase๋ฅผ ํ™œ์šฉํ•ด ์‚ฌ์šฉ์ž ์ธ์ฆ ๋ฐ ํ‘ธ์‹œ ์•Œ๋ฆผ ํ† ํฐ ๊ด€๋ฆฌ
  • Firebase Functions๋ฅผ ์‚ฌ์šฉํ•ด ์•Œ๋ฆผ ์ „์†ก ๋กœ์ง ์ฒ˜๋ฆฌ
  1. ์•Œ๋ฆผ ๋‚ด์šฉ ๊ตฌ์„ฑ
  • ์‚ฌ์šฉ์ž๊ฐ€ ์„ค์ •ํ•œ ๋‚ฎ์ž  ์ข…๋ฃŒ ์‹œ๊ฐ„๊ณผ ๊ฐœ์ธํ™”๋œ ๋ฉ”์‹œ์ง€๋ฅผ ํฌํ•จํ•˜์—ฌ ์นœ๊ตฌ๋“ค์—๊ฒŒ ์ง๊ด€์ ์ธ ์ •๋ณด๋ฅผ ์ œ๊ณต
  • ์•Œ๋ฆผ ์ œ๋ชฉ: "์นœ๊ตฌ๊ฐ€ ์ž ๋“ค์—ˆ์–ด์š”!"
  • ์•Œ๋ฆผ ๋‚ด์šฉ: "์˜ค์ „ 10:30์— ์ „ํ™”๋กœ ๋‚ฎ์ž ์„ ๊นจ์›Œ์ฃผ์„ธ์š”!"
  1. ์‚ฌ์šฉ์ž ๊ฒฝํ—˜ ๊ฐœ์„ 
  • ๋‚ฎ์ž  ๋ชจ๋“œ ์ง„์ž… ์‹œ ๋ฌด์Œ ๋ชจ๋“œ ํ•ด์ œ๋ฅผ ์œ ๋„ํ•˜์—ฌ ์•Œ๋ฆผ์„ ๋†“์น˜์ง€ ์•Š๋„๋ก ์•ˆ๋‚ด
  • ์•Œ๋ฆผ ์ „์†ก ์„ฑ๊ณต ์—ฌ๋ถ€๋ฅผ ์‹ค์‹œ๊ฐ„์œผ๋กœ ํ™•์ธํ•˜์—ฌ ์‚ฌ์šฉ์ž ์‹ ๋ขฐ๋„ ์ฆ๋Œ€

  • ํ‘ธ์‹œ ์•Œ๋ฆผ ์ „์†ก ๋กœ์ง
func sendPushNotification(authToken: String?) {
    guard let authToken = authToken else {
        resultMessage = "No auth token available"
        return
    }
    
    let functions = Functions.functions(region: "asia-northeast3")
    let data: [String: Any] = [
        "token": "์‚ฌ์šฉ์ž ๊ณ ์œ  FCM ํ† ํฐ", 
        "title": "์นœ๊ตฌ๊ฐ€ ์ž ๋“ค์—ˆ์–ด์š”!",
        "body": "\(wakeupTime().formatted(date: .omitted, time: .shortened))์— ์ „ํ™”๋กœ ๋‚ฎ์ž ์„ ๊นจ์›Œ์ฃผ์„ธ์š”!"
    ]
    
    functions.httpsCallable("sendPushNotification").call(data) { result, error in
        if let error = error as NSError? {
            print("Error sending push notification: \(error.localizedDescription)")
            DispatchQueue.main.async {
                resultMessage = "Error sending push notification: \(error.localizedDescription)"
            }
            return
        }
        
        if let response = result?.data as? [String: Any], let responseMessage = response["response"] as? String {
            DispatchQueue.main.async {
                resultMessage = responseMessage
            }
        } else {
            DispatchQueue.main.async {
                resultMessage = "Unknown response"
            }
        }
    }
}

  • ๋‚ฎ์ž  ๋ชจ๋“œ ์ง„์ž… ์‹œ ์•Œ๋ฆผ ํŠธ๋ฆฌ๊ฑฐ
func moveNextAndNoti() {
    moveNext = true
    fetchAuthToken { token in
        self.authToken = token
        sendPushNotification(authToken: token)
    }
}

  • ์•Œ๋ฆผ์„ ์œ„ํ•œ Firebase ์ธ์ฆ ํ† ํฐ ํš๋“
func fetchAuthToken(completion: @escaping (String?) -> Void) {
    if let currentUser = Auth.auth().currentUser {
        currentUser.getIDToken { token, error in
            if let error = error {
                print("Error fetching ID token: \(error.localizedDescription)")
                completion(nil)
            } else {
                completion(token)
            }
        }
    } else {
        print("No user is signed in")
        completion(nil)
    }
}

3. ์ •ํ•ด์ง„ ๋‚ฎ์ž  ์‹œ๊ฐ„์ด ์ข…๋ฃŒ๋˜๋ฉด CallKit์„ ํ†ตํ•ด ์•Œ๋ฆผ ์ „ํ™”๋ฅผ ์ œ๊ณตํ•˜๋Š” ๊ธฐ๋Šฅ

Naptune ์•ฑ์˜ ๋…ํŠนํ•œ ๊ธฐ๋Šฅ ์ค‘ ํ•˜๋‚˜๋Š” ๋‚ฎ์ž  ์‹œ๊ฐ„์ด ์ข…๋ฃŒ๋˜์—ˆ์„ ๋•Œ CallKit์„ ์‚ฌ์šฉํ•˜์—ฌ ์•Œ๋ฆผ ์ „ํ™”๋ฅผ ์ œ๊ณตํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด ๊ธฐ๋Šฅ์€ ์‚ฌ์šฉ์ž๊ฐ€ ๋‚ฎ์ž ์—์„œ ๊นจ์–ด๋‚˜์•ผ ํ•  ์‹œ๊ฐ„์„ ๋†“์น˜์ง€ ์•Š๋„๋ก ๋•๋Š” ๋™์‹œ์—, ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ํ’๋ถ€ํ•˜๊ฒŒ ๋งŒ๋“ญ๋‹ˆ๋‹ค.

  1. ํƒ€์ด๋จธ ์‹œ๊ฐ„ ์ข…๋ฃŒ ์ด๋ฒคํŠธ ๊ฐ์ง€
  • ์•ฑ์€ ์„ค์ •๋œ ๋‚ฎ์ž  ์‹œ๊ฐ„์ด ์ข…๋ฃŒ๋˜์—ˆ์„ ๋•Œ startCall() ๋ฉ”์„œ๋“œ๋ฅผ ์‹คํ–‰
  • ๋‚จ์€ ์‹œ๊ฐ„์ด 0 ์ดํ•˜๊ฐ€ ๋˜๋ฉด CallManager๊ฐ€ ํ˜ธ์ถœ

  1. CallKit์„ ํ†ตํ•œ ์ „ํ™” ์•Œ๋ฆผ
  • CallManager๋Š” CallKit์„ ํ™œ์šฉํ•˜์—ฌ ๊ฐ€์ƒ ์ „ํ™”๋ฅผ ์ƒ์„ฑ
  • ์‚ฌ์šฉ์ž ๋””๋ฐ”์ด์Šค์—์„œ ์‹ค์ œ ์ „ํ™”๊ฐ€ ์˜จ ๊ฒƒ์ฒ˜๋Ÿผ ์•Œ๋ฆผ์ด ํ‘œ์‹œ๋˜๋ฉฐ, ์ด๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž๊ฐ€ ํ™•์‹คํžˆ ์•Œ๋ฆผ์„ ๋ฐ›์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

  1. CXProvider๋ฅผ ์‚ฌ์šฉํ•œ ์ „ํ™” ์ƒ์„ฑ
  • CXProvider์™€ CXCallController๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ฐ€์ƒ ์ „ํ™”๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ๊ด€๋ฆฌ
  • UUID๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๊ฐ ์ „ํ™” ํ˜ธ์ถœ์„ ๊ณ ์œ ํ•˜๊ฒŒ ์‹๋ณ„
  • CXCallUpdate๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ „ํ™”์˜ ์„ธ๋ถ€ ์ •๋ณด(์˜ˆ: ๋ฐœ์‹ ์ž ์ด๋ฆ„, ํ•ธ๋“ค)๋ฅผ ์„ค์ •
final class CallManager: NSObject, CXProviderDelegate {
   let provider = CXProvider(configuration: CXProviderConfiguration())
   let callController = CXCallController()

   static let shared = CallManager()

   private override init() {
       super.init()
       provider.setDelegate(self, queue: nil)
   }

   func reportIncomingCall(id: UUID, handle: String) {
       let update = CXCallUpdate()
       update.remoteHandle = CXHandle(type: .generic, value: handle)

       provider.reportNewIncomingCall(with: id, update: update) { error in
           if let error = error {
               print("Error reporting incoming call: \(error.localizedDescription)")
           } else {
               print("Call Reported")
           }
       }
   }
}

  • NapProgress์˜ ํƒ€์ด๋จธ ์ข…๋ฃŒ ์ด๋ฒคํŠธ
 func startCall() {
    DispatchQueue.main.asyncAfter(deadline: .now(), execute: {
        let callManager = CallManager.shared
        let id = UUID()
        callManager.reportIncomingCall(id: id, handle: "์—ฌ์šฐ")
    })
}

4. ์ผ์–ด๋‚˜๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด ์ปค์Šคํ…€ ์นด๋ฉ”๋ผ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•˜๋Š” ๊ธฐ๋Šฅ

Naptune ์•ฑ์€ ๋‚ฎ์ž  ํ›„ ์‚ฌ์šฉ์ž๊ฐ€ ๊ธฐ์ƒ ์ƒํƒœ๋ฅผ ๊ธฐ๋กํ•  ์ˆ˜ ์žˆ๋„๋ก ์ปค์Šคํ…€ ์นด๋ฉ”๋ผ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•˜๋Š” ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์ด ๊ธฐ๋Šฅ์€ ์‚ฌ์šฉ์ž๊ฐ€ ๊ธฐ์ƒ ์‚ฌ์ง„์„ ์ดฌ์˜ํ•˜๊ณ  ์ด๋ฅผ ์•ฑ ํ”ผ๋“œ์— ์—…๋กœ๋“œํ•˜์—ฌ ์นœ๊ตฌ๋“ค๊ณผ ๊ณต์œ ํ•  ์ˆ˜ ์žˆ๋Š” ๋…ํŠนํ•œ ๊ฒฝํ—˜์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

4-1. ์ผ์–ด๋‚˜๊ธฐ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด ์นด๋ฉ”๋ผ ํ™”๋ฉด์œผ๋กœ ์ „ํ™˜

  • SwiftUI์˜ UIViewControllerRepresentable์„ ์‚ฌ์šฉํ•˜์—ฌ UIKit์˜ UIViewController๋ฅผ SwiftUI ๋ทฐ์— ํ†ตํ•ฉ
  • NapPhotoView๊ฐ€ ์ปค์Šคํ…€ ์นด๋ฉ”๋ผ ํ™”๋ฉด์„ ์ œ๊ณตํ•˜๋ฉฐ, ์นด๋ฉ”๋ผ ์ œ์–ด๋Š” CameraCoordinator์—์„œ ๊ด€๋ฆฌ
struct NapPhotoView: UIViewControllerRepresentable {
   @Binding var capturedImage: UIImage?

   func makeUIViewController(context: Context) -> UIViewController {
       let containerVC = UIViewController()
       let overlayView = createOverlayView(context: context, containerVC: containerVC)
       containerVC.view.addSubview(overlayView)
       return containerVC
   }
   
   func createOverlayView(context: Context, containerVC: UIViewController) -> UIView {
       // ์นด๋ฉ”๋ผ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋ฐ ์ปค์Šคํ…€ UI ๊ตฌ์„ฑ
       let cameraView = CameraPreview()
       let shutterButton = UIButton(type: .system)
       shutterButton.addTarget(context.coordinator, action: #selector(CameraCoordinator.shutterButtonTapped), for: .touchUpInside)
       // SnapKit์„ ์‚ฌ์šฉํ•˜์—ฌ ๋ ˆ์ด์•„์›ƒ ๊ตฌ์„ฑ
       return cameraView
   }
}

4-2. ์นด๋ฉ”๋ผ ์ดˆ๊ธฐํ™” ๋ฐ ์„ธ์…˜ ๊ตฌ์„ฑ

  • AVFoundation์„ ์‚ฌ์šฉํ•˜์—ฌ ์ „๋ฉด/ํ›„๋ฉด ์นด๋ฉ”๋ผ๋ฅผ ์„ค์ •ํ•˜๊ณ , ์‚ฌ์ง„ ์บก์ฒ˜ ๋ฐ ์‹ค์‹œ๊ฐ„ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ™”๋ฉด ์ œ๊ณต
  • ์‚ฌ์šฉ์ž ํŽธ์˜๋ฅผ ์œ„ํ•œ ์ปค์Šคํ…€ UI(์…”ํ„ฐ ๋ฒ„ํŠผ, ์นด๋ฉ”๋ผ ์ „ํ™˜ ๋ฒ„ํŠผ ๋“ฑ) ์ œ๊ณต
  • CameraCoordinator๋Š” ์นด๋ฉ”๋ผ ์ œ์–ด ๋กœ์ง์„ ๋‹ด๋‹น
class CameraCoordinator: NSObject, AVCapturePhotoCaptureDelegate {
    func setupCameraSession() {
        guard let frontCamera = getCameraDevice(position: .front) else { return }
        let frontCameraInput = try AVCaptureDeviceInput(device: frontCamera)
        captureSession.addInput(frontCameraInput)
        setupPhotoOutput()
    }
    
    func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
        if let data = photo.fileDataRepresentation(), let image = UIImage(data: data) {
            capturedImage = image // ์บก์ฒ˜๋œ ์ด๋ฏธ์ง€๋ฅผ ์ €์žฅ
        }
    }
}

4-3. ์‚ฌ์ง„ ์ดฌ์˜ ๋ฐ ์บก์ฒ˜ ๊ฒฐ๊ณผ ์ฒ˜๋ฆฌ

  • AVCapturePhotoOutput์„ ํ†ตํ•ด ์‚ฌ์ง„์„ ์ดฌ์˜ํ•˜๊ณ , ์ „๋ฉด ์นด๋ฉ”๋ผ์˜ ์ขŒ์šฐ ๋ฐ˜์ „๋œ ์ด๋ฏธ์ง€๋Š” ์ ์ ˆํžˆ ์ˆ˜์ •ํ•˜์—ฌ ์ €์žฅ
  • CameraPreview ํด๋ž˜์Šค๋Š” ์‹ค์‹œ๊ฐ„ ์นด๋ฉ”๋ผ ํ™”๋ฉด๊ณผ ์‚ฌ์ง„ ์บก์ฒ˜ ํ›„ ๊ฒฐ๊ณผ๋ฅผ ํ‘œ์‹œ
class CameraPreview: UIView {
    let captureSession = AVCaptureSession()
    let previewLayer = AVCaptureVideoPreviewLayer()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupCamera()
    }
    
    private func setupCamera() {
        // ์ „๋ฉด ์นด๋ฉ”๋ผ๋กœ ์„ธ์…˜ ๊ตฌ์„ฑ
        let frontCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front)
        let input = try? AVCaptureDeviceInput(device: frontCamera!)
        captureSession.addInput(input!)
        captureSession.startRunning()
    }
}

4-4. ์™œ ์นด๋ฉ”๋ผ ๊ตฌํ˜„์— UIKit์„ ์„ ํƒํ–ˆ๋Š”๊ฐ€?

  1. ์ •๊ตํ•œ ์นด๋ฉ”๋ผ ์ œ์–ด ํ•„์š”
  • SwiftUI๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์นด๋ฉ”๋ผ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋ฐ˜๋ฉด, UIKit์˜ AVFoundation์€ ์นด๋ฉ”๋ผ ์„ธ์…˜, ์‚ฌ์ง„ ์ดฌ์˜, ์ „ํ™˜ ๋“ฑ ๋ณต์žกํ•œ ๊ธฐ๋Šฅ์„ ์™„๋ฒฝํžˆ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

  1. ์„ฑ๋Šฅ ์ตœ์ ํ™” ๋ฐ ์œ ์—ฐ์„ฑ
  • AVCaptureSession๊ณผ AVCapturePhotoOutput์„ ํ†ตํ•ด ์‹ค์‹œ๊ฐ„ ๋ฏธ๋ฆฌ๋ณด๊ธฐ์™€ ์‚ฌ์ง„ ์บก์ฒ˜๋ฅผ ํšจ์œจ์ ์œผ๋กœ ๊ตฌํ˜„ ๊ฐ€๋Šฅ
  • ๋ณต์žกํ•œ ์ปค์Šคํ…€ ๋ ˆ์ด์•„์›ƒ(์…”ํ„ฐ ๋ฒ„ํŠผ, ์ „ํ™˜ ๋ฒ„ํŠผ ๋“ฑ)์„ ๊ตฌ์„ฑํ•˜๊ธฐ ์œ„ํ•ด UIKit์˜ UIView์™€ SnapKit์„ ์‚ฌ์šฉ

  1. SwiftUI์™€์˜ ์™„๋ฒฝํ•œ ํ†ตํ•ฉ
  • UIViewControllerRepresentable์„ ์‚ฌ์šฉํ•˜์—ฌ SwiftUI์˜ ์žฅ์ (๋ฐ์ดํ„ฐ ๋ฐ”์ธ๋”ฉ, ์„ ์–ธํ˜• UI)๊ณผ UIKit์˜ ๊ฐ•๋ ฅํ•œ ๊ธฐ๋Šฅ์„ ๊ฒฐํ•ฉ

5. ์นœ๊ตฌ๋“ค์˜ ๋‚ฎ์ž  ๊ธฐ๋ก๊ณผ ์ƒํƒœ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ํ”ผ๋“œ ๊ธฐ๋Šฅ

Naptune ์•ฑ์€ ์นœ๊ตฌ๋“ค์˜ ๋‚ฎ์ž  ๊ธฐ๋ก๊ณผ ์ƒํƒœ๋ฅผ ํ•œ๋ˆˆ์— ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ์†Œ์…œ ํ”ผ๋“œ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๋Š” ์นœ๊ตฌ๋“ค์˜ ๋‚ฎ์ž  ์‹œ๊ฐ„, ์ƒํƒœ, ๊ทธ๋ฆฌ๊ณ  ๊ด€๋ จ ์ฝ”๋ฉ˜ํŠธ๋ฅผ ํ™•์ธํ•˜๋ฉฐ ์„œ๋กœ์˜ ๊ฒฝํ—˜์„ ๊ณต์œ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๊ธฐ๋Šฅ์€ Firebase Firestore์™€ Storage๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ๊ด€๋ฆฌํ•˜๋ฉฐ, SwiftUI๋กœ ์‚ฌ์šฉ์ž ์นœํ™”์ ์ธ UI๋ฅผ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.


5-1. FireStore ๋ฐ์ดํ„ฐ ์—ฐ๋™

  • FeedViewModel์—์„œ Firebase Firestore์˜ posts ์ปฌ๋ ‰์…˜ ๋ฐ์ดํ„ฐ๋ฅผ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๊ฐ€์ ธ์™€ SwiftUI ๋ทฐ์— ๋ฐ˜์˜
  • ๋ฐ์ดํ„ฐ๋Š” @Published ๋ณ€์ˆ˜๋ฅผ ํ†ตํ•ด ์ž๋™ ์—…๋ฐ์ดํŠธ๋˜์–ด ์‚ฌ์šฉ์ž์—๊ฒŒ ์ตœ์‹  ์ •๋ณด๋ฅผ ์ œ๊ณต
struct Post: Codable {
    let id: String
    let nickname: String
    let profileImageUrl: String
    let imageUrl: String
    let sleepComent: String
    let sleepStatusLevel: Double
    let sleepTime: Double
    let date: Date
}

  • FeedViewModel์€ Firestore์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์™€ posts ๋ฐฐ์—ด๋กœ ๊ด€๋ฆฌ
class FeedViewModel: ObservableObject {
    @Published var posts: [Post] = []

    init() {
        fetchPosts()
    }

    func fetchPosts() {
        let db = Firestore.firestore()
        db.collection("posts")
          .order(by: "date", descending: true)
          .getDocuments { snapshot, error in
            if let error = error {
                print("Error getting documents: \(error)")
                return
            }
            self.posts = snapshot?.documents.compactMap { document -> Post? in
                try? document.data(as: Post.self)
            } ?? []
        }
    }
}

5-2. ํฌ์ŠคํŠธ ๋“ฑ๋ก ๋ฐ ์—…๋กœ๋“œ

  • FeedRegisterViewModel์€ ์‚ฌ์šฉ์ž๊ฐ€ ๋“ฑ๋กํ•œ ๋ฐ์ดํ„ฐ๋ฅผ Firebase์— ์—…๋กœ๋“œํ•˜๋Š” ๋กœ์ง์„ ๊ด€๋ฆฌ
class FeedRegisterViewModel {
    var selectedImage: UIImage?
    var sleepComent: String? = ""
    var sleepStatusLevel: Double?

    func uploadPost(capturedImage: UIImage?, sleepComent: String, sleepStatusLevel: Double, sleepTime: Double) async {
        let nickname = UserDefaults.standard.string(forKey: "nickname") ?? "Unknown"
        let profileImageUrl = UserDefaults.standard.string(forKey: "profileImageUrl") ?? ""
        
        guard let capturedImage = capturedImage else { return }
        guard let imageUrl = await uploadImage(uiImage: capturedImage) else { return }
        
        // ํ˜„์žฌ ๋กœ๊ทธ์ธ๋œ ์‚ฌ์šฉ์ž์˜ UID ๊ฐ€์ ธ์˜ค๊ธฐ
        guard let currentUserID = Auth.auth().currentUser?.uid else {
            print("Error: No current user ID found.")
            return
        }
        
        // Firestore์— ์ €์žฅํ•  Post ๊ฐ์ฒด ์ƒ์„ฑ
        let post = Post(id: UUID().uuidString, nickname: nickname, profileImageUrl: profileImageUrl, imageUrl: imageUrl, sleepComent: sleepComent, sleepStatusLevel: sleepStatusLevel, sleepTime: sleepTime, date: Date())
        
        // Firestore์— ๋ฌธ์„œ ID๋ฅผ ์‚ฌ์šฉ์ž UID๋กœ ์„ค์ •ํ•˜๊ณ  ๋ฐ์ดํ„ฐ ์ €์žฅ
        let postReference = Firestore.firestore().collection("posts").document(UUID().uuidString)
        
        do {
            let encodedData = try Firestore.Encoder().encode(post)
            try await postReference.setData(encodedData)
        } catch {
            print("DEBUG: Failed to upload post with error \(error.localizedDescription)")
        }
    }
    
    func uploadImage(uiImage: UIImage) async -> String? {
        guard let imageData = uiImage.jpegData(compressionQuality: 0.5) else { return nil }
        let fileName = UUID().uuidString
        let reference = Storage.storage().reference(withPath: "/images/\(fileName).jpg")
        
        do {
            let _ = try await reference.putDataAsync(imageData)
            let url = try await reference.downloadURL()
            return url.absoluteString
        } catch {
            print("DEBUG: Failed to upload image with error \(error.localizedDescription)")
            return nil
        }
    }
}

6. ๋‹ค์ด๋‚˜๋ฏน ์•„์ผ๋žœ๋“œ์™€ ๋ผ์ด๋ธŒ ์•กํ‹ฐ๋น„ํ‹ฐ๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ๋ชจ๋“œ์—์„œ๋„ ๋‚จ์€ ๋‚ฎ์ž  ์‹œ๊ฐ„์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ํ™•์ธํ•˜๋Š” ๊ธฐ๋Šฅ

Naptune ์•ฑ์€ Dynamic Island์™€ Live Activities๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์•ฑ์ด ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ƒํƒœ์—์„œ๋„ ๋‚จ์€ ๋‚ฎ์ž  ์‹œ๊ฐ„์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๋Š” ์ž ์ž๋Š” ๋™์•ˆ iPhone ํ™”๋ฉด์„ ๋ณผ ํ•„์š” ์—†์ด Dynamic Island๋‚˜ ์ž ๊ธˆ ํ™”๋ฉด์—์„œ ๋ฐ”๋กœ ๋‚จ์€ ์‹œ๊ฐ„์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

6-1. ActivityKit์˜ Activity ๊ฐ์ฒด๋ฅผ ์‚ฌ์šฉํ•ด ๋‚จ์€ ๋‚ฎ์ž  ์‹œ๊ฐ„์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค.

  • NapStatusAttributes์™€ ContentState๋ฅผ ํ†ตํ•ด ์ƒํƒœ ๋ฐ์ดํ„ฐ๋ฅผ ๊ด€๋ฆฌ
struct NapStatusAttributes: ActivityAttributes {
   struct ContentState: Codable, Hashable {
       var remainingTime: Int = 0
   }
}

6-2. Live Activity ๊ตฌํ˜„

  • NapStatusLiveActivity๋Š” Dynamic Island์™€ ์ž ๊ธˆ ํ™”๋ฉด์—์„œ ํ‘œ์‹œ๋  UI๋ฅผ ์ •์˜
struct NapStatusLiveActivity: Widget {
   var body: some WidgetConfiguration {
       ActivityConfiguration(for: NapStatusAttributes.self) { context in
           ZStack {
               RoundedRectangle(cornerRadius: 15)
                   .fill(LinearGradient(
                       gradient: Gradient(colors: [Color("์ƒ๋‹จ๋„ค์ด๋น„"), Color("ํ•˜๋‹จ๋„ค์ด๋น„")]),
                       startPoint: .top,
                       endPoint: .bottom
                   ))
                   .opacity(0.5)
               
               VStack {
                   Text("๋‚จ์€ ๋‚ฎ์ž  ์‹œ๊ฐ„")
                       .font(.headline)
                       .foregroundColor(.white)
                   Text("\(context.state.remainingTime / 3600)์‹œ๊ฐ„ \((context.state.remainingTime % 3600) / 60)๋ถ„ \((context.state.remainingTime % 3600) % 60)์ดˆ")
                       .font(.largeTitle)
                       .foregroundColor(.white)
               }
           }
       } dynamicIsland: { context in
           DynamicIsland {
               DynamicIslandExpandedRegion(.center) {
                   Text("Nap - \(context.state.remainingTime / 60)๋ถ„ ๋‚จ์Œ")
                       .font(.headline)
                       .foregroundColor(.white)
               }
               DynamicIslandExpandedRegion(.bottom) {
                   Text("์‹œ๊ฐ„์ด ๋‹ค ๋˜๋ฉด ์•Œ๋žŒ์ด ์šธ๋ฆฝ๋‹ˆ๋‹ค.")
                       .font(.footnote)
                       .foregroundColor(.gray)
               }
           } compactLeading: {
               Image(systemName: "bolt.fill")
           } compactTrailing: {
               Text("\(context.state.remainingTime / 60)๋ถ„")
           } minimal: {
               Image(systemName: "bolt.fill")
           }
       }
   }
}

6-3. ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ

  • Activity.update(using:)๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋ผ์ด๋ธŒ ์ƒํƒœ๋ฅผ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๋ณ€๊ฒฝ
func updateLiveActivity(remainingTime: Int) {
    guard let activity = Activity<NapStatusAttributes>.activities.first else { return }
    let updatedContentState = NapStatusAttributes.ContentState(remainingTime: remainingTime)
    Task {
        await activity.update(using: updatedContentState)
    }
}
 

License and Copyright

ยฉ 2024 ์ด์Šนํ˜„ (SeungHyeon Lee). All rights reserved.

  • ๋ณธ GitHub ๋ฆฌ๋“œ๋ฏธ๋Š” ํŒ€ ํ”„๋กœ์ ํŠธ ๋„ตํŠ (Naptune)์„ ์†Œ๊ฐœํ•˜๊ธฐ ์œ„ํ•ด ์ž‘์„ฑ๋˜์—ˆ์œผ๋ฉฐ, ๋ฆฌ๋“œ๋ฏธ์˜ ๋ชจ๋“  ๋‚ด์šฉ์€ ์ด์Šนํ˜„(SeungHyeon Lee)์ด ์ง์ ‘ ์ž‘์„ฑํ•˜์˜€์Šต๋‹ˆ๋‹ค.

  • ์ด ํ”„๋กœ์ ํŠธ๋Š” iOS Developer 2๋ช…, Back-end 1๋ช…, Design 2๋ช…์œผ๋กœ ๊ตฌ์„ฑ๋œ ํŒ€์ด ํ˜‘๋ ฅํ•˜์—ฌ ์ง„ํ–‰ํ•œ ๊ฒฐ๊ณผ๋ฌผ์ž…๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ๋ฆฌ๋“œ๋ฏธ์— ํฌํ•จ๋œ ๋ชจ๋“  ํ…์ŠคํŠธ, ์ด๋ฏธ์ง€ ๋ฐฐ์น˜, ์„ค๋ช…, ๊ธฐ์ˆ  ์Šคํƒ ์†Œ๊ฐœ ๋“ฑ์€ ์ €์˜ ์ž‘์—…์ž„์„ ๋ฐํž™๋‹ˆ๋‹ค.

  • ๋ฆฌ๋“œ๋ฏธ์™€ ๊ด€๋ จํ•˜์—ฌ ๋ฌธ์˜๊ฐ€ ํ•„์š”ํ•˜์‹  ๊ฒฝ์šฐ ์•„๋ž˜ ์ด๋ฉ”์ผ๋กœ ์—ฐ๋ฝํ•ด ์ฃผ์„ธ์š”: [email protected]

About

๐ŸŒ™ ๋ผ๋ฒค๋” ํŒœํ•˜๋‹ˆ ํŒ€์˜ ํ–‰๋ณตํ•œ ๋‚ฎ์ž ์ž๊ธฐ Naptune

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • Swift 100.0%