diff --git a/SodaLive.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/SodaLive.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/SodaLive.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000..410e712
--- /dev/null
+++ b/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,104 @@
+{
+ "pins" : [
+ {
+ "identity" : "abseil-cpp-binary",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/google/abseil-cpp-binary.git",
+ "state" : {
+ "revision" : "bfc0b6f81adc06ce5121eb23f628473638d67c5c",
+ "version" : "1.2022062300.0"
+ }
+ },
+ {
+ "identity" : "firebase-ios-sdk",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/firebase/firebase-ios-sdk.git",
+ "state" : {
+ "revision" : "df2171b0c6afb9e9d4f7e07669d558c510b9f6be",
+ "version" : "10.13.0"
+ }
+ },
+ {
+ "identity" : "googleappmeasurement",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/google/GoogleAppMeasurement.git",
+ "state" : {
+ "revision" : "03b9beee1a61f62d32c521e172e192a1663a5e8b",
+ "version" : "10.13.0"
+ }
+ },
+ {
+ "identity" : "googledatatransport",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/google/GoogleDataTransport.git",
+ "state" : {
+ "revision" : "aae45a320fd0d11811820335b1eabc8753902a40",
+ "version" : "9.2.5"
+ }
+ },
+ {
+ "identity" : "googleutilities",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/google/GoogleUtilities.git",
+ "state" : {
+ "revision" : "c38ce365d77b04a9a300c31061c5227589e5597b",
+ "version" : "7.11.5"
+ }
+ },
+ {
+ "identity" : "grpc-binary",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/google/grpc-binary.git",
+ "state" : {
+ "revision" : "f1b366129d1125be7db83247e003fc333104b569",
+ "version" : "1.50.2"
+ }
+ },
+ {
+ "identity" : "gtm-session-fetcher",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/google/gtm-session-fetcher.git",
+ "state" : {
+ "revision" : "d415594121c9e8a4f9d79cecee0965cf35e74dbd",
+ "version" : "3.1.1"
+ }
+ },
+ {
+ "identity" : "leveldb",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/firebase/leveldb.git",
+ "state" : {
+ "revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b",
+ "version" : "1.22.2"
+ }
+ },
+ {
+ "identity" : "nanopb",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/firebase/nanopb.git",
+ "state" : {
+ "revision" : "819d0a2173aff699fb8c364b6fb906f7cdb1a692",
+ "version" : "2.30909.0"
+ }
+ },
+ {
+ "identity" : "promises",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/google/promises.git",
+ "state" : {
+ "revision" : "e70e889c0196c76d22759eb50d6a0270ca9f1d9e",
+ "version" : "2.3.1"
+ }
+ },
+ {
+ "identity" : "swift-protobuf",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-protobuf.git",
+ "state" : {
+ "revision" : "ce20dc083ee485524b802669890291c0d8090170",
+ "version" : "1.22.1"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/SodaLive/Resources/Assets.xcassets/splash_bubble.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/splash_bubble.imageset/Contents.json
new file mode 100644
index 0000000..f20a3f8
--- /dev/null
+++ b/SodaLive/Resources/Assets.xcassets/splash_bubble.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "splash_bubble.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SodaLive/Resources/Assets.xcassets/splash_bubble.imageset/splash_bubble.png b/SodaLive/Resources/Assets.xcassets/splash_bubble.imageset/splash_bubble.png
new file mode 100644
index 0000000..647356e
Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/splash_bubble.imageset/splash_bubble.png differ
diff --git a/SodaLive/Resources/Assets.xcassets/splash_logo.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/splash_logo.imageset/Contents.json
new file mode 100644
index 0000000..ae9fc61
--- /dev/null
+++ b/SodaLive/Resources/Assets.xcassets/splash_logo.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "splash_logo.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SodaLive/Resources/Assets.xcassets/splash_logo.imageset/splash_logo.png b/SodaLive/Resources/Assets.xcassets/splash_logo.imageset/splash_logo.png
new file mode 100644
index 0000000..e56aed8
Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/splash_logo.imageset/splash_logo.png differ
diff --git a/SodaLive/Resources/Assets.xcassets/splash_text.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/splash_text.imageset/Contents.json
new file mode 100644
index 0000000..1214b3d
--- /dev/null
+++ b/SodaLive/Resources/Assets.xcassets/splash_text.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "splash_text.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SodaLive/Resources/Assets.xcassets/splash_text.imageset/splash_text.png b/SodaLive/Resources/Assets.xcassets/splash_text.imageset/splash_text.png
new file mode 100644
index 0000000..98e27f8
Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/splash_text.imageset/splash_text.png differ
diff --git a/SodaLive/Resources/Assets.xcassets/vividnext_logo.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/vividnext_logo.imageset/Contents.json
new file mode 100644
index 0000000..2937c4b
--- /dev/null
+++ b/SodaLive/Resources/Assets.xcassets/vividnext_logo.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "vividnext_logo.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/SodaLive/Resources/Assets.xcassets/vividnext_logo.imageset/vividnext_logo.png b/SodaLive/Resources/Assets.xcassets/vividnext_logo.imageset/vividnext_logo.png
new file mode 100644
index 0000000..ce3f969
Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/vividnext_logo.imageset/vividnext_logo.png differ
diff --git a/SodaLive/Resources/Debug/GoogleService-Info.plist b/SodaLive/Resources/Debug/GoogleService-Info.plist
new file mode 100644
index 0000000..17ddffb
--- /dev/null
+++ b/SodaLive/Resources/Debug/GoogleService-Info.plist
@@ -0,0 +1,34 @@
+
+
+
+
+ CLIENT_ID
+ 758414412471-rgobklhtodvt7c2crp4cmh7q1kd89nha.apps.googleusercontent.com
+ REVERSED_CLIENT_ID
+ com.googleusercontent.apps.758414412471-rgobklhtodvt7c2crp4cmh7q1kd89nha
+ API_KEY
+ AIzaSyAYscsMZzW1m0btcs6c2zkI8pLtcDE_Eqg
+ GCM_SENDER_ID
+ 758414412471
+ PLIST_VERSION
+ 1
+ BUNDLE_ID
+ kr.co.vividnext.sodalive.dev
+ PROJECT_ID
+ sodalive-test
+ STORAGE_BUCKET
+ sodalive-test.appspot.com
+ IS_ADS_ENABLED
+
+ IS_ANALYTICS_ENABLED
+
+ IS_APPINVITE_ENABLED
+
+ IS_GCM_ENABLED
+
+ IS_SIGNIN_ENABLED
+
+ GOOGLE_APP_ID
+ 1:758414412471:ios:866b0814ab94bdf77a5b32
+
+
\ No newline at end of file
diff --git a/SodaLive/Sources/App/AppDelegate.swift b/SodaLive/Sources/App/AppDelegate.swift
new file mode 100644
index 0000000..e04318b
--- /dev/null
+++ b/SodaLive/Sources/App/AppDelegate.swift
@@ -0,0 +1,110 @@
+//
+// AppDelegate.swift
+// SodaLive
+//
+// Created by klaus on 2023/08/09.
+//
+
+import UIKit
+
+import FirebaseCore
+import FirebaseMessaging
+
+class AppDelegate: UIResponder, UIApplicationDelegate {
+
+ private let gcmMessageIDKey = "gcm.message_id"
+
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
+ FirebaseApp.configure()
+
+ Messaging.messaging().delegate = self
+
+ // For iOS 10 display notification (sent via APNS)
+ UNUserNotificationCenter.current().delegate = self
+
+ let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
+ UNUserNotificationCenter.current().requestAuthorization(
+ options: authOptions,
+ completionHandler: {_, _ in })
+
+ application.registerForRemoteNotifications()
+ UIApplication.shared.applicationIconBadgeNumber = 0
+
+ return true
+ }
+
+ func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
+ Messaging.messaging().appDidReceiveMessage(userInfo)
+ // Print message ID.
+ if let messageID = userInfo[gcmMessageIDKey] {
+ DEBUG_LOG("Message ID: \(messageID)")
+ }
+
+ // Print full message.
+ DEBUG_LOG("userInfo: \(userInfo)")
+
+ completionHandler(UIBackgroundFetchResult.newData)
+ }
+
+ func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ UserDefaults.set(deviceToken, forKey: .devicePushToken)
+ Messaging.messaging().apnsToken = deviceToken
+ }
+
+ func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
+ }
+}
+
+extension AppDelegate: MessagingDelegate {
+ func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
+ if let fcmToken = fcmToken {
+ DEBUG_LOG("fcmToken: \(fcmToken)")
+ UserDefaults.set(fcmToken, forKey: .pushToken)
+ }
+ }
+}
+
+extension AppDelegate : UNUserNotificationCenterDelegate {
+
+ // Receive displayed notifications for iOS 10 devices.
+ func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification,
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+ ) {
+ let userInfo = notification.request.content.userInfo
+
+ // With swizzling disabled you must let Messaging know about the message, for Analytics
+ Messaging.messaging().appDidReceiveMessage(userInfo)
+
+ // ...
+
+ // Print full message.
+ DEBUG_LOG("userInfo: \(userInfo)")
+
+ // Change this to your preferred presentation option
+ completionHandler([.banner, .badge, .sound])
+ }
+
+ func userNotificationCenter(_ center: UNUserNotificationCenter,
+ didReceive response: UNNotificationResponse,
+ withCompletionHandler completionHandler: @escaping () -> Void) {
+ let userInfo = response.notification.request.content.userInfo
+
+ // With swizzling disabled you must let Messaging know about the message, for Analytics
+ Messaging.messaging().appDidReceiveMessage(userInfo)
+
+ let roomIdString = userInfo["suda_room_id"] as? String
+ let audioContentIdString = userInfo["audio_content_id"] as? String
+
+ if let roomIdString = roomIdString, let roomId = Int(roomIdString), roomId > 0 {
+ AppState.shared.pushRoomId = roomId
+ }
+
+ if let audioContentIdString = audioContentIdString, let audioContentId = Int(audioContentIdString), audioContentId > 0 {
+ AppState.shared.pushAudioContentId = audioContentId
+ }
+
+ completionHandler()
+ }
+}
diff --git a/SodaLive/Sources/App/AppState.swift b/SodaLive/Sources/App/AppState.swift
new file mode 100644
index 0000000..a762fc0
--- /dev/null
+++ b/SodaLive/Sources/App/AppState.swift
@@ -0,0 +1,58 @@
+//
+// AppState.swift
+// SodaLive
+//
+// Created by klaus on 2023/08/09.
+//
+
+import Foundation
+
+class AppState: ObservableObject {
+ static let shared = AppState()
+
+ private var appStepBackStack = [AppStep]()
+
+ @Published private(set) var appStep: AppStep = .splash
+
+ @Published var isShowPlayer = false {
+ didSet {
+ if isShowPlayer {
+ }
+ }
+ }
+
+ @Published var isShowNotificationSettingsDialog = false
+
+ @Published var pushRoomId = 0
+ @Published var pushChannelId = 0
+ @Published var pushAudioContentId = 0
+ @Published var roomId = 0 {
+ didSet {
+ if roomId <= 0 {
+ isShowPlayer = false
+ }
+ }
+ }
+
+ func setAppStep(step: AppStep) {
+ switch step {
+ case .splash, .main:
+ appStepBackStack.removeAll()
+
+ default:
+ appStepBackStack.append(appStep)
+ }
+
+ DispatchQueue.main.async {
+ self.appStep = step
+ }
+ }
+
+ func back() {
+ if let step = appStepBackStack.popLast() {
+ self.appStep = step
+ } else {
+ self.appStep = .main
+ }
+ }
+}
diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift
new file mode 100644
index 0000000..14aa8b4
--- /dev/null
+++ b/SodaLive/Sources/App/AppStep.swift
@@ -0,0 +1,14 @@
+//
+// AppStep.swift
+// SodaLive
+//
+// Created by klaus on 2023/08/09.
+//
+
+import Foundation
+
+enum AppStep {
+ case splash
+
+ case main
+}
diff --git a/SodaLive/Sources/App/SodaLiveApp.swift b/SodaLive/Sources/App/SodaLiveApp.swift
index fee81fd..2ec257f 100644
--- a/SodaLive/Sources/App/SodaLiveApp.swift
+++ b/SodaLive/Sources/App/SodaLiveApp.swift
@@ -6,12 +6,81 @@
//
import SwiftUI
+import AppTrackingTransparency
+
+import FirebaseDynamicLinks
@main
struct SodaLiveApp: App {
+
+ @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+
+ func handleIncomingDynamicLink(_ dynamicLink: DynamicLink) {
+ guard let url = dynamicLink.url else {
+ DEBUG_LOG("That's weired. My dynamic link object has no url")
+ return
+ }
+
+ DEBUG_LOG("incoming link parameter is \(url.absoluteString)")
+
+ let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems
+ let roomId = queryItems?.filter({$0.name == "room_id"}).first?.value
+ let channelId = queryItems?.filter({$0.name == "channel_id"}).first?.value
+ let audioContentId = queryItems?.filter({$0.name == "audio_content_id"}).first?.value
+
+ if let roomId = roomId {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ AppState.shared.pushRoomId = Int(roomId) ?? 0
+ }
+ }
+
+ if let channelId = channelId {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ AppState.shared.pushChannelId = Int(channelId) ?? 0
+ }
+ }
+
+ if let audioContentId = audioContentId {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ AppState.shared.pushAudioContentId = Int(audioContentId) ?? 0
+ }
+ }
+ }
+
var body: some Scene {
WindowGroup {
ContentView()
+ .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
+ UIApplication.shared.applicationIconBadgeNumber = 0
+
+ ATTrackingManager.requestTrackingAuthorization { _ in }
+ }
+ .onOpenURL { url in
+ DEBUG_LOG("I have received a URL through a custom scheme! \(url.absoluteString)")
+ if let scheme = url.scheme {
+ if scheme == "kr.co.vividnext.yozm" {
+ if let dynamicLink = DynamicLinks.dynamicLinks().dynamicLink(fromCustomSchemeURL: url) {
+ self.handleIncomingDynamicLink(dynamicLink)
+ } else {
+ DEBUG_LOG("dynamic link fail")
+ }
+ } else {
+ DynamicLinks.dynamicLinks().handleUniversalLink(url) { dynamicLink, error in
+ guard error == nil else {
+ DEBUG_LOG("Found an error! \(error!.localizedDescription)")
+ DEBUG_LOG("dynamic link fail")
+ return
+ }
+
+ if let dynamicLink = dynamicLink {
+ self.handleIncomingDynamicLink(dynamicLink)
+ } else {
+ DEBUG_LOG("dynamic link fail")
+ }
+ }
+ }
+ }
+ }
}
}
}
diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift
index 6e72655..2b0475d 100644
--- a/SodaLive/Sources/ContentView.swift
+++ b/SodaLive/Sources/ContentView.swift
@@ -8,14 +8,21 @@
import SwiftUI
struct ContentView: View {
+ @StateObject private var appState = AppState.shared
+
var body: some View {
- VStack {
- Image(systemName: "globe")
- .imageScale(.large)
- .foregroundColor(.accentColor)
- Text("Hello, world!")
+ ZStack {
+ Color.black.ignoresSafeArea()
+
+ switch appState.appStep {
+ case .splash:
+ SplashView()
+
+ default:
+ EmptyView()
+ .frame(width: 0, height: 0, alignment: .topLeading)
+ }
}
- .padding()
}
}
diff --git a/SodaLive/Sources/Debug/Utils/Constants.swift b/SodaLive/Sources/Debug/Utils/Constants.swift
new file mode 100644
index 0000000..c4f5ca3
--- /dev/null
+++ b/SodaLive/Sources/Debug/Utils/Constants.swift
@@ -0,0 +1,16 @@
+//
+// Constants.swift
+// SodaLive-dev
+//
+// Created by klaus on 2023/08/09.
+//
+
+import Foundation
+
+let BASE_URL = "https://test-api.sodalive.net"
+let APPLY_YOZM_CREATOR = "https://forms.gle/mmhJKmijjVRnwtdZ7"
+
+let AGORA_APP_ID = "b96574e191a9430fa54c605528aa3ef7"
+let AGORA_APP_CERTIFICATE = "ae18ade3afcf4086bd4397726eb0654c"
+
+let BOOTPAY_APP_ID = "6242a7772701800023f68b2f"
diff --git a/SodaLive/Sources/Extensions/ColorExtension.swift b/SodaLive/Sources/Extensions/ColorExtension.swift
new file mode 100644
index 0000000..742e4ad
--- /dev/null
+++ b/SodaLive/Sources/Extensions/ColorExtension.swift
@@ -0,0 +1,40 @@
+//
+// ColorExtension.swift
+// SodaLive
+//
+// Created by klaus on 2023/08/09.
+//
+// https://seons-dev.tistory.com/174
+//
+
+import SwiftUI
+
+extension Color {
+ init(hex: String) {
+ let scanner = Scanner(string: hex)
+ _ = scanner.scanString("#")
+
+ var rgb: UInt64 = 0
+ scanner.scanHexInt64(&rgb)
+
+ let r = Double((rgb >> 16) & 0xFF) / 255.0
+ let g = Double((rgb >> 8) & 0xFF) / 255.0
+ let b = Double((rgb >> 0) & 0xFF) / 255.0
+ self.init(red: r, green: g, blue: b)
+ }
+}
+
+extension UIColor {
+ convenience init(hex: String, alpha: CGFloat = 1.0) {
+ let scanner = Scanner(string: hex)
+ _ = scanner.scanString("#")
+
+ var rgb: UInt64 = 0
+ scanner.scanHexInt64(&rgb)
+
+ let r = Double((rgb >> 16) & 0xFF) / 255.0
+ let g = Double((rgb >> 8) & 0xFF) / 255.0
+ let b = Double((rgb >> 0) & 0xFF) / 255.0
+ self.init(red: r, green: g, blue: b, alpha: alpha)
+ }
+}
diff --git a/SodaLive/Sources/Extensions/DateExtension.swift b/SodaLive/Sources/Extensions/DateExtension.swift
new file mode 100644
index 0000000..d983c3a
--- /dev/null
+++ b/SodaLive/Sources/Extensions/DateExtension.swift
@@ -0,0 +1,21 @@
+//
+// DateExtension.swift
+// yozm
+//
+// Created by klaus on 2022/05/27.
+//
+
+import Foundation
+
+extension Date {
+ func convertDateFormat(dateFormat: String = "yyyy.MM.dd") -> String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = dateFormat
+ formatter.locale = Locale(identifier: "ko")
+ return formatter.string(from: self)
+ }
+
+ func currentTimeMillis() -> Int64 {
+ return Int64(self.timeIntervalSince1970 * 1000)
+ }
+}
diff --git a/SodaLive/Sources/Extensions/IntExtension.swift b/SodaLive/Sources/Extensions/IntExtension.swift
new file mode 100644
index 0000000..310a247
--- /dev/null
+++ b/SodaLive/Sources/Extensions/IntExtension.swift
@@ -0,0 +1,32 @@
+//
+// IntExtension.swift
+// yozm
+//
+// Created by klaus on 2022/06/21.
+//
+
+import Foundation
+
+extension Int64 {
+ func durationText() -> String {
+ let duration = self
+
+ let convertedTime = Int(duration / 1000)
+ let hour = Int(convertedTime / 3600)
+ let minute = Int(convertedTime / 60) % 60
+ let second = Int(convertedTime % 60)
+
+ // update UI
+ var timeText = [String]()
+
+ if hour > 0 {
+ timeText.append(String(hour))
+ timeText.append(String(format: "%02d", minute))
+ } else {
+ timeText.append(String(format: "%02d", minute))
+ timeText.append(String(format: "%02d", second))
+ }
+
+ return timeText.joined(separator: ":")
+ }
+}
diff --git a/SodaLive/Sources/Extensions/StringExtension.swift b/SodaLive/Sources/Extensions/StringExtension.swift
new file mode 100644
index 0000000..d49fda3
--- /dev/null
+++ b/SodaLive/Sources/Extensions/StringExtension.swift
@@ -0,0 +1,43 @@
+//
+// StringExtension.swift
+// yozm
+//
+// Created by klaus on 2022/06/03.
+//
+
+import Foundation
+
+extension Optional where Wrapped == String {
+ func isNullOrBlank() -> Bool {
+ return self == nil || self!.trimmingCharacters(in: .whitespaces).isEmpty
+ }
+}
+
+extension String {
+ func convertDateFormat(from: String, to: String) -> String {
+ let fromFormatter = DateFormatter()
+ fromFormatter.dateFormat = from
+ fromFormatter.timeZone = TimeZone(identifier: TimeZone.current.identifier)
+
+ if let date = fromFormatter.date(from: self) {
+ return date.convertDateFormat(dateFormat: to)
+ } else {
+ return self
+ }
+ }
+
+ func substring(from: Int, to: Int) -> String {
+ guard from < count, to >= 0, to - from >= 0 else {
+ return ""
+ }
+
+ // Index 값 획득
+ let startIndex = index(self.startIndex, offsetBy: from)
+ let endIndex = index(self.startIndex, offsetBy: to + 1) // '+1'이 있는 이유: endIndex는 문자열의 마지막 그 다음을 가리키기 때문
+
+ // 파싱
+ return String(self[startIndex ..< endIndex])
+
+ // 출처 - https://ios-development.tistory.com/379
+ }
+}
diff --git a/SodaLive/Sources/Extensions/UserDefaultsExtension.swift b/SodaLive/Sources/Extensions/UserDefaultsExtension.swift
new file mode 100644
index 0000000..b572a77
--- /dev/null
+++ b/SodaLive/Sources/Extensions/UserDefaultsExtension.swift
@@ -0,0 +1,72 @@
+//
+// UserDefaultsExtension.swift
+// yozm
+//
+// Created by klaus on 2022/05/20.
+//
+
+import Foundation
+
+enum UserDefaultsKey: String, CaseIterable {
+ case auth
+ case role
+ case coin
+ case token
+ case email
+ case userId
+ case nickname
+ case pushToken
+ case profileImage
+ case voipPushToken
+ case devicePushToken
+ case isContentPlayLoop
+ case isFollowedCreatorLive
+ case isViewedOnboardingView
+ case notShowingEventPopupId
+}
+
+extension UserDefaults {
+ static func set(_ value: Bool, forKey key: UserDefaultsKey) {
+ let key = key.rawValue
+ UserDefaults.standard.set(value, forKey: key)
+ }
+
+ static func bool(forKey key: UserDefaultsKey) -> Bool {
+ let key = key.rawValue
+ return UserDefaults.standard.bool(forKey: key)
+ }
+
+ static func set(_ value: String, forKey key: UserDefaultsKey) {
+ let key = key.rawValue
+ UserDefaults.standard.set(value, forKey: key)
+ }
+
+ static func string(forKey key: UserDefaultsKey) -> String {
+ let key = key.rawValue
+ return UserDefaults.standard.string(forKey: key) ?? ""
+ }
+
+ static func set(_ value: Int, forKey key: UserDefaultsKey) {
+ let key = key.rawValue
+ UserDefaults.standard.set(value, forKey: key)
+ }
+
+ static func int(forKey key: UserDefaultsKey) -> Int {
+ let key = key.rawValue
+ return UserDefaults.standard.integer(forKey: key)
+ }
+
+ static func set(_ value: Data, forKey key: UserDefaultsKey) {
+ let key = key.rawValue
+ UserDefaults.standard.set(value, forKey: key)
+ }
+
+ static func data(forKey key: UserDefaultsKey) -> Data? {
+ let key = key.rawValue
+ return UserDefaults.standard.data(forKey: key)
+ }
+
+ static func reset() {
+ UserDefaultsKey.allCases.forEach { UserDefaults.standard.removeObject(forKey: $0.rawValue) }
+ }
+}
diff --git a/SodaLive/Sources/Extensions/ViewExtension.swift b/SodaLive/Sources/Extensions/ViewExtension.swift
new file mode 100644
index 0000000..df65c57
--- /dev/null
+++ b/SodaLive/Sources/Extensions/ViewExtension.swift
@@ -0,0 +1,49 @@
+//
+// ViewExtension.swift
+// yozm
+//
+// Created by klaus on 2022/05/19.
+//
+
+import SwiftUI
+import Combine
+
+extension View {
+ func screenSize() -> CGRect {
+ return UIScreen.main.bounds
+ }
+
+ // 출처 - https://stackoverflow.com/a/58606176
+ func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
+ clipShape( RoundedCorner(radius: radius, corners: corners) )
+ }
+
+ func hideKeyboard() {
+ UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
+ }
+
+ /// A backwards compatible wrapper for iOS 14 `onChange`
+ @ViewBuilder func valueChanged(value: T, onChange: @escaping (T) -> Void) -> some View {
+ if #available(iOS 14.0, *) {
+ self.onChange(of: value, perform: onChange)
+ } else {
+ self.onReceive(Just(value)) { (value) in
+ onChange(value)
+ }
+ }
+ }
+
+ @ViewBuilder func scrollContentBackgroundHidden() -> some View {
+ if #available(iOS 16.0, *) {
+ self.scrollContentBackground(.hidden)
+ }
+ }
+
+ @ViewBuilder func progressColor(color: Color) -> some View {
+ if #available(iOS 16.0, *) {
+ self.tint(color)
+ } else {
+ self.accentColor(color)
+ }
+ }
+}
diff --git a/SodaLive/Sources/Shape/RoundedCorner.swift b/SodaLive/Sources/Shape/RoundedCorner.swift
new file mode 100644
index 0000000..16587e3
--- /dev/null
+++ b/SodaLive/Sources/Shape/RoundedCorner.swift
@@ -0,0 +1,20 @@
+//
+// RoundedCorner.swift
+// SodaLive
+//
+// Created by klaus on 2023/08/09.
+//
+
+import SwiftUI
+
+// 출처 - https://stackoverflow.com/a/58606176
+
+struct RoundedCorner: Shape {
+ var radius: CGFloat = .infinity
+ var corners: UIRectCorner = .allCorners
+
+ func path(in rect: CGRect) -> Path {
+ let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
+ return Path(path.cgPath)
+ }
+}
diff --git a/SodaLive/Sources/Splash/SplashView.swift b/SodaLive/Sources/Splash/SplashView.swift
new file mode 100644
index 0000000..2b02528
--- /dev/null
+++ b/SodaLive/Sources/Splash/SplashView.swift
@@ -0,0 +1,106 @@
+//
+// SplashView.swift
+// SodaLive
+//
+// Created by klaus on 2023/08/09.
+//
+
+import SwiftUI
+import FirebaseRemoteConfig
+
+struct SplashView: View {
+
+ @State private var isShowForcedUpdatePopup = false
+ @State private var isShowUpdatePopup = false
+
+ var body: some View {
+ ZStack(alignment: .top) {
+ LinearGradient(
+ gradient: Gradient(colors: [Color(hex: "a0e2ff"), Color(hex: "ecfaff")]),
+ startPoint: .bottom,
+ endPoint: .top
+ ).ignoresSafeArea()
+
+ Image("splash_bubble")
+ .padding(.top, 11)
+
+ VStack(spacing: 0) {
+ Image("splash_text")
+ .padding(.top, 111)
+
+ Image("splash_logo")
+ .padding(.top, 77.3)
+
+ Spacer()
+
+ Image("vividnext_logo")
+ .padding(.bottom, 36)
+ }
+ }
+ .onAppear {
+ fetchLastestVersion()
+ }
+ }
+
+ private func fetchLastestVersion() {
+ let remoteConfig = RemoteConfig.remoteConfig()
+ let configSettings = RemoteConfigSettings()
+ configSettings.minimumFetchInterval = 300
+ remoteConfig.configSettings = configSettings
+
+ remoteConfig.fetch { status, error in
+ if status == .success {
+ remoteConfig.activate { changed, error in
+ checkAppVersion(latestVersion: remoteConfig["ios_latest_version"].stringValue)
+ }
+ } else {
+ withAnimation {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
+ AppState.shared.setAppStep(step: .main)
+ }
+ }
+ }
+ }
+ }
+
+ private func checkAppVersion(latestVersion: String?) {
+ let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
+
+ if let latestVersion = latestVersion, let version = version {
+ let latestVersions = latestVersion.split(separator: ".")
+ let versions = version.split(separator: ".")
+
+ let latestMajor = Int(latestVersions[0])!
+ let latestMinor = Int(latestVersions[1])!
+ let latestPatch = Int(latestVersions[2])!
+
+ let major = Int(versions[0])!
+ let minor = Int(versions[1])!
+ let patch = Int(versions[2])!
+
+ if latestMajor > major || (latestMajor == major && latestMinor > minor) {
+ self.isShowForcedUpdatePopup = true
+ } else if latestMajor == major && latestMinor == minor && latestPatch > patch {
+ self.isShowUpdatePopup = true
+ } else {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ withAnimation {
+ AppState.shared.setAppStep(step: .main)
+ }
+ }
+ }
+ } else {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+ withAnimation {
+ AppState.shared.setAppStep(step: .main)
+ }
+ }
+ }
+ }
+}
+
+struct SplashView_Previews: PreviewProvider {
+ static var previews: some View {
+ SplashView()
+ }
+}
diff --git a/SodaLive/Sources/Utils/Constants.swift b/SodaLive/Sources/Utils/Constants.swift
new file mode 100644
index 0000000..857ff86
--- /dev/null
+++ b/SodaLive/Sources/Utils/Constants.swift
@@ -0,0 +1,16 @@
+//
+// Constants.swift
+// SodaLive
+//
+// Created by klaus on 2023/08/09.
+//
+
+import Foundation
+
+let BASE_URL = "https://api.sodalive.net"
+let APPLY_YOZM_CREATOR = "https://forms.gle/mmhJKmijjVRnwtdZ7"
+
+let AGORA_APP_ID = "e34e40046e9847baba3adfe2b8ffb4f6"
+let AGORA_APP_CERTIFICATE = "15cadeea4ba94ff7b091c9a10f4bf4a6"
+
+let BOOTPAY_APP_ID = "64c35be1d25985001dc50c88"
diff --git a/SodaLive/Sources/Utils/Log.swift b/SodaLive/Sources/Utils/Log.swift
new file mode 100644
index 0000000..b2e8011
--- /dev/null
+++ b/SodaLive/Sources/Utils/Log.swift
@@ -0,0 +1,23 @@
+//
+// Log.swift
+// yozm
+//
+// Created by klaus on 2022/05/20.
+//
+
+import Foundation
+
+func DEBUG_LOG(_ msg: String, file: String = #file, function: String = #function, line: Int = #line) {
+ #if DEBUG
+ let filename = file.split(separator: "/").last ?? ""
+ let funcName = function.split(separator: "(").first ?? ""
+ print("👻 [\(filename)] \(funcName)(\(line)): \(msg)")
+ #endif
+}
+
+func ERROR_LOG(_ msg: String, file: String = #file, function: String = #function, line: Int = #line) {
+ let filename = file.split(separator: "/").last ?? ""
+ let funcName = function.split(separator: "(").first ?? ""
+ print("🤯😡 [\(filename)] \(funcName)(\(line)): \(msg)")
+}
+