├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── -feat--기능-구현.md │ └── bug_report.md ├── pull_request_template.md └── workflows │ └── merge-to-main.yml ├── .gitignore ├── README.md ├── alsongDalsong.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── swiftpm │ └── Package.resolved ├── alsongDalsong ├── ASAudioKit │ ├── ASAudioAnalyzer.swift │ ├── ASAudioDemo │ │ ├── ASAudioDemoApp.swift │ │ ├── ASAudioKitDemoView.swift │ │ ├── ASAudioKitDemoViewModel.swift │ │ ├── Assets.xcassets │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── AudioVisualizerView.swift │ │ └── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── ASAudioKit.xcodeproj │ │ ├── project.pbxproj │ │ ├── project.xcworkspace │ │ │ └── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── ASAudioKit.xcscheme │ ├── ASAudioKit │ │ ├── ASAudioKit.docc │ │ │ └── ASAudioKit.md │ │ ├── ASAudioKit.h │ │ ├── ASAudioPlayer │ │ │ ├── ASAudioPlayer.swift │ │ │ └── README.md │ │ └── ASAudioRecorder │ │ │ ├── ASAudioRecorder.swift │ │ │ └── README.md │ └── ASAudioKitTests │ │ ├── ASAudioPlayerTests.swift │ │ ├── ASAudioRecorderTests.swift │ │ └── TestData │ │ └── PlayTestDrum.wav ├── ASCacheKit │ ├── ASCacheKit.xcodeproj │ │ ├── project.pbxproj │ │ ├── project.xcworkspace │ │ │ └── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ ├── ASCacheKit.xcscheme │ │ │ └── ASCacheKitTests.xcscheme │ ├── ASCacheKit │ │ ├── CacheAssembly.swift │ │ └── Core │ │ │ ├── Extension │ │ │ └── String+Encrypt.swift │ │ │ └── Source │ │ │ ├── ASCacheManager.swift │ │ │ ├── DiskCache.swift │ │ │ └── MemoryCache.swift │ ├── ASCacheKitDemo │ │ ├── ASCacheKitDemoApp.swift │ │ ├── ASCacheKitDemoView.swift │ │ ├── ASCacheKitDemoViewModel.swift │ │ ├── Assets.xcassets │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ │ └── Contents.json │ │ └── Toast.swift │ ├── ASCacheKitProtocol │ │ ├── CacheManagerProtocol.swift │ │ ├── CacheOption.swift │ │ ├── DiskCacheManagerProtocol.swift │ │ └── MemoryCacheManagerProtocol.swift │ └── ASCacheKitTests │ │ ├── ASCacheKitTests.swift │ │ └── Mock │ │ ├── MockDiskCacheManager.swift │ │ └── MockMemoryCacheManager.swift ├── ASContainer │ ├── ASContainer.xcodeproj │ │ └── project.pbxproj │ └── ASContainer │ │ └── DIContainer.swift ├── ASDecoder │ ├── ASDecoder.xcodeproj │ │ ├── project.pbxproj │ │ ├── project.xcworkspace │ │ │ └── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ ├── ASDecoder.xcscheme │ │ │ └── ASDecoderTests.xcscheme │ ├── ASDecoder │ │ └── ASDecoder.swift │ ├── ASDecoderDemo │ │ ├── ASDecoderDemoApp.swift │ │ ├── ASDecoderDemoView.swift │ │ ├── ASDecoderDemoViewModel.swift │ │ ├── Assets.xcassets │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── DemoModels.swift │ │ ├── JSONDataScenarios.swift │ │ ├── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ │ └── Contents.json │ │ └── ViewModifiers.swift │ └── ASDecoderTests │ │ └── ASDecoderTests.swift ├── ASEncoder │ ├── ASEncoder.xcodeproj │ │ ├── project.pbxproj │ │ ├── project.xcworkspace │ │ │ └── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ ├── ASEncoder.xcscheme │ │ │ └── ASEncoderTests.xcscheme │ ├── ASEncoder │ │ └── ASEncoder.swift │ ├── ASEncoderDemo │ │ ├── ASEncoderDemoApp.swift │ │ ├── Assets.xcassets │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── DemoModels.swift │ │ ├── EncodingDemoView.swift │ │ ├── EncodingDemoViewModel.swift │ │ └── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ └── ASEncoderTests │ │ └── ASEncoderTests.swift ├── ASEntity │ ├── ASEntity.xcodeproj │ │ ├── project.pbxproj │ │ └── project.xcworkspace │ │ │ └── contents.xcworkspacedata │ └── ASEntity │ │ ├── Answer.swift │ │ ├── GameState.swift │ │ ├── Mode.swift │ │ ├── Music.swift │ │ ├── Player.swift │ │ ├── Playlist.swift │ │ ├── Record.swift │ │ ├── Room.swift │ │ └── Status.swift ├── ASLogKit │ ├── ASLogKit.xcodeproj │ │ ├── project.pbxproj │ │ └── project.xcworkspace │ │ │ └── contents.xcworkspacedata │ └── ASLogKit │ │ └── Logger.swift ├── ASMusicKit │ ├── ASMusicKit.xcodeproj │ │ ├── project.pbxproj │ │ └── project.xcworkspace │ │ │ └── contents.xcworkspacedata │ └── ASMusicKit │ │ ├── ASMusicAPI.swift │ │ ├── ASMusicKit.docc │ │ └── ASMusicKit.md │ │ └── CGColor+Hex.swift ├── ASNetworkKit │ ├── ASFirebaseDemo │ │ ├── Resources │ │ │ └── Info.plist │ │ └── Sources │ │ │ ├── AppDelegate.swift │ │ │ ├── Base.lproj │ │ │ └── LaunchScreen.storyboard │ │ │ ├── Lobby │ │ │ ├── LobbyViewController.swift │ │ │ └── LobbyViewModel.swift │ │ │ ├── Main │ │ │ └── MainViewController.swift │ │ │ └── SceneDelegate.swift │ ├── ASNetworkKit.xcodeproj │ │ ├── project.pbxproj │ │ ├── project.xcworkspace │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata │ │ │ │ └── swiftpm │ │ │ │ └── Package.resolved │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── ASNetworkKit.xcscheme │ ├── ASNetworkKit │ │ ├── ASNetworkErrors.swift │ │ ├── ASNetworkManager.swift │ │ ├── Endpoint.swift │ │ ├── Firebase │ │ │ ├── ASFirebaseAuth.swift │ │ │ ├── ASFirebaseDatabase.swift │ │ │ └── ASFirebaseStorage.swift │ │ ├── FirebaseEndpoint.swift │ │ ├── HTTPContentType.swift │ │ ├── HTTPMethod.swift │ │ ├── NetworkAssembly.swift │ │ ├── Protocols │ │ │ ├── ASFirebaseAuthProtocol.swift │ │ │ ├── ASFirebaseDatabaseProtocol.swift │ │ │ ├── ASFirebaseStorageProtocol.swift │ │ │ ├── ASNetworkManagerProtocol.swift │ │ │ └── URLSessionProtocol.swift │ │ ├── RequestBuilder.swift │ │ └── ResourceEndpoint.swift │ └── ASNetworkKitTests │ │ ├── ASNetworkKitTests.swift │ │ └── MockURLSession.swift ├── ASRepository │ ├── ASRepository.xcodeproj │ │ └── project.pbxproj │ ├── ASRepository │ │ ├── Protocols │ │ │ ├── MainRepositoryProtocol.swift │ │ │ └── RepositoryProtocols.swift │ │ ├── Repositories │ │ │ ├── AnswersRepository.swift │ │ │ ├── AvatarRepository.swift │ │ │ ├── DataDownloadRepository.swift │ │ │ ├── GameStateRepository.swift │ │ │ ├── GameStatusRepository.swift │ │ │ ├── HummingResultRepository.swift │ │ │ ├── MainRepository.swift │ │ │ ├── PlayersRepository.swift │ │ │ ├── RecordsRepository.swift │ │ │ ├── RoomActionRepository.swift │ │ │ ├── RoomInfoRepository.swift │ │ │ ├── SelectedRecordsRepository.swift │ │ │ └── SubmitsRepository.swift │ │ └── RepsotioryAssembly.swift │ └── ASRepositoryProtocol │ │ └── ASRepositoryProtocol.h └── alsongDalsong │ ├── .swiftlint.yml │ ├── alsongDalsong.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── alsongDalsong.xcscheme │ └── alsongDalsong │ ├── Localizable.xcstrings │ ├── Resources │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── icon-20@2x.png │ │ │ ├── icon-20@3x.png │ │ │ ├── icon-29@2x.png │ │ │ ├── icon-29@3x.png │ │ │ ├── icon-38@2x.png │ │ │ ├── icon-38@3x.png │ │ │ ├── icon-40@2x.png │ │ │ ├── icon-40@3x.png │ │ │ ├── icon-60@2x.png │ │ │ ├── icon-60@3x.png │ │ │ ├── icon-64@2x.png │ │ │ ├── icon-64@3x.png │ │ │ ├── icon-68@2x.png │ │ │ ├── icon-76@2x.png │ │ │ ├── icon-83_5@2x.png │ │ │ └── ios-marketing.png │ │ ├── Contents.json │ │ ├── ModeImage │ │ │ ├── Contents.json │ │ │ ├── harmony.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── harmony.png │ │ │ ├── humming.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── humming.png │ │ │ ├── instant.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── instant.png │ │ │ ├── sync.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── sync.png │ │ │ └── tts.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── tts.png │ │ ├── asBlack.colorset │ │ │ └── Contents.json │ │ ├── asGreen.colorset │ │ │ └── Contents.json │ │ ├── asLightGray.colorset │ │ │ └── Contents.json │ │ ├── asLightRed.colorset │ │ │ └── Contents.json │ │ ├── asLightSky.colorset │ │ │ └── Contents.json │ │ ├── asMint.colorset │ │ │ └── Contents.json │ │ ├── asOrange.colorset │ │ │ └── Contents.json │ │ ├── asShadow.colorset │ │ │ └── Contents.json │ │ ├── asSystem.colorset │ │ │ └── Contents.json │ │ ├── asYellow.colorset │ │ │ └── Contents.json │ │ └── logo.imageset │ │ │ ├── Contents.json │ │ │ └── logo.png │ ├── Fonts │ │ └── DoHyeon-Regular.ttf │ ├── Info.plist │ ├── SampleRecord.m4a │ └── Secret.xcconfig │ └── Sources │ ├── AppDelegate.swift │ ├── Base.lproj │ └── LaunchScreen.storyboard │ ├── Components │ ├── SpeechBubbleCell.swift │ ├── SwiftUIComponents │ │ ├── ASButtonStyle.swift │ │ ├── ModeView.swift │ │ ├── ProfileView.swift │ │ └── SnapperView.swift │ └── UIKitComponents │ │ ├── ASAvatarCircleView.swift │ │ ├── ASButton.swift │ │ ├── ASPanel.swift │ │ ├── ASRefreshButton.swift │ │ ├── ASTextField.swift │ │ ├── Alert │ │ ├── ASAlertController.swift │ │ ├── DefaultAlertController.swift │ │ ├── InputAlertController.swift │ │ ├── LoadingAlertController.swift │ │ └── SingleButtonAlertController.swift │ │ ├── CornerImageView.swift │ │ ├── GuideLabel.swift │ │ ├── NicknamePanel.swift │ │ ├── ProgressBar.swift │ │ ├── SubmissionStatusView.swift │ │ ├── WaveForm.swift │ │ └── WaveFormWrapper.swift │ ├── Extensions │ ├── CGColor+.swift │ ├── Color+Hex.swift │ ├── Font+.swift │ ├── String+.swift │ ├── UIColor+Hex.swift │ ├── UIFont+.swift │ ├── UIImage+Flip.swift │ ├── UIView+.swift │ └── UIViewController+present.swift │ ├── SceneDelegate.swift │ ├── Utils │ ├── AudioHelper.swift │ ├── Debouncer.swift │ └── NickNameGenerator.swift │ └── Views │ ├── Game │ └── GameNavigationController.swift │ ├── Guide │ └── GuideViewController.swift │ ├── Humming │ ├── HummingView.swift │ └── HummingViewModel.swift │ ├── Lobby │ ├── LobbyView.swift │ ├── LobbyViewController.swift │ └── LobbyViewModel.swift │ ├── MusicPanel │ ├── ASMusicPlayerView.swift │ ├── MusicPanel.swift │ └── MusicPanelViewModel.swift │ ├── Onboarding │ ├── OnboardingViewController.swift │ └── OnboardingViewModel.swift │ ├── RecordingPanel │ ├── AudioButtonState.swift │ ├── RecordingPanel.swift │ └── RecordingPanelViewModel.swift │ ├── Rehumming │ ├── RehummingView.swift │ └── RehummingViewModel.swift │ ├── Result │ ├── HummingResultTableViewDiffableDataSource.swift │ ├── HummingResultViewController.swift │ ├── HummingResultViewModel+Entity.swift │ ├── HummingResultViewModel.swift │ └── MusicPanelView.swift │ ├── SelectMusic │ ├── ASMusicItemCell.swift │ ├── ASSearchBar.swift │ ├── SelectMusicView.swift │ ├── SelectMusicViewController.swift │ └── SelectMusicViewModel.swift │ └── SubmitAnswer │ ├── SelectAnswerView.swift │ ├── SubmitAnswerViewController.swift │ └── SubmitAnswerViewModel.swift └── firebase ├── .firebaserc ├── firebase.json └── functions ├── .gitignore ├── FirebaseAdmin.js ├── api ├── ChangeMode.js ├── ChangeRecordOrder.js ├── CreateRoom.js ├── ExitRoom.js ├── GetUserAvatar.js ├── JoinRoom.js ├── ResetGame.js ├── SetAnswer.js ├── StartGame.js ├── SubmitAnswer.js ├── SubmitAnswerV2.js ├── SubmitMusic.js ├── SubmitMusicV2.js ├── UploadRecord.js └── UploadRecordV2.js ├── common ├── GameHelper.js └── RoomHelper.js ├── index.js ├── package-lock.json ├── package.json └── trigger ├── onRecordAdded.js └── onRemovePlayer.js /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @psangwon62 @Tltlbo @moral-life @Sonny-Kor 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/-feat--기능-구현.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "[FEAT] 기능 구현" 3 | about: 기능 구현을 위한 이슈입니다. 4 | title: "[FEAT]" 5 | labels: feat 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 🛠 Issue 11 | 12 | 13 | 14 | ## 📝 To-do 15 | 16 | - [ ] to do 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: 버그 신고를 위한 이슈입니다. 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 🐞 BUG 11 | 12 | 13 | 14 | ## 📝 To Reproduce 15 | 16 | 1. Go to '...' 17 | 2. Click on '...' 18 | 3. Scroll down to '...' 19 | 4. See error 20 | 21 | ## 🤔Expected behavior 22 | 23 | 24 | 25 | ## 📸Screenshots 26 | 27 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## What is this PR? 2 | - PR에 대한 설명 3 | 4 | ## PR Type 5 | - [ ] Bugfix 6 | - [ ] Chore 7 | - [ ] New feature (기능을 추가하는 feat) 8 | - [ ] Breaking change (기존의 기능이 동작하지 않을 수 있는 fix/feat) 9 | - [ ] Documentation Update 10 | 11 | ## ScreenShot(if available) 12 | |기능|스크린샷| 13 | |:--:|:--:| 14 | |GIF|| 15 | 16 | ## Further comments 17 | -------------------------------------------------------------------------------- /.github/workflows/merge-to-main.yml: -------------------------------------------------------------------------------- 1 | name: Merge to Main 2 | 3 | on: 4 | workflow_dispatch: # 수동 실행을 위한 트리거 5 | 6 | jobs: 7 | merge-to-main: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v3 13 | 14 | - name: Fetch all branches 15 | run: git fetch origin +refs/heads/*:refs/remotes/origin/* 16 | 17 | - name: Merge dev to main 18 | run: | 19 | git fetch origin 20 | git checkout main 21 | git pull origin main 22 | git merge origin/dev --no-ff --no-edit 23 | git push origin main 24 | -------------------------------------------------------------------------------- /alsongDalsong.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 12 | 13 | 15 | 16 | 18 | 19 | 21 | 22 | 24 | 25 | 27 | 28 | 30 | 31 | 33 | 34 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /alsongDalsong/ASAudioKit/ASAudioAnalyzer.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import Foundation 3 | 4 | public enum ASAudioAnalyzer { 5 | public static func analyze(data: Data, samplesCount: Int) async throws -> [CGFloat] { 6 | let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".m4a") 7 | try data.write(to: tempURL) 8 | let file = try AVAudioFile(forReading: tempURL) 9 | 10 | guard 11 | let format = AVAudioFormat( 12 | commonFormat: .pcmFormatFloat32, 13 | sampleRate: file.fileFormat.sampleRate, 14 | channels: file.fileFormat.channelCount, 15 | interleaved: false 16 | ), 17 | let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(file.length)) 18 | else { 19 | return [] 20 | } 21 | 22 | try file.read(into: buffer) 23 | guard let floatChannelData = buffer.floatChannelData else { 24 | return [] 25 | } 26 | 27 | let frameLength = Int(buffer.frameLength) 28 | let samples = Array(UnsafeBufferPointer(start: floatChannelData[0], count: frameLength)) 29 | var result = [CGFloat]() 30 | let chunkedSamples = samples.chunked(into: samples.count / samplesCount) 31 | 32 | for chunk in chunkedSamples { 33 | let squaredSum = chunk.reduce(0) { $0 + $1 * $1 } 34 | let averagePower = squaredSum / Float(chunk.count) 35 | let decibels = 10 * log10(max(averagePower, Float.ulpOfOne)) 36 | 37 | let newAmplitude = 1.8 * pow(10.0, decibels / 20.0) 38 | let clampedAmplitude = min(max(CGFloat(newAmplitude), 0), 1) 39 | result.append(clampedAmplitude) 40 | } 41 | 42 | try? FileManager.default.removeItem(at: tempURL) 43 | 44 | return result 45 | } 46 | } 47 | 48 | extension Array { 49 | func chunked(into size: Int) -> [[Element]] { 50 | stride(from: 0, to: count, by: size).map { 51 | Array(self[$0 ..< Swift.min($0 + size, count)]) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /alsongDalsong/ASAudioKit/ASAudioDemo/ASAudioDemoApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ASAudioDemoApp.swift 3 | // ASAudioDemo 4 | // 5 | // Created by 박진성 on 11/16/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct ASAudioDemoApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ASAudioKitDemoView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /alsongDalsong/ASAudioKit/ASAudioDemo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /alsongDalsong/ASAudioKit/ASAudioDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /alsongDalsong/ASAudioKit/ASAudioDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /alsongDalsong/ASAudioKit/ASAudioDemo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /alsongDalsong/ASAudioKit/ASAudioKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /alsongDalsong/ASAudioKit/ASAudioKit/ASAudioKit.docc/ASAudioKit.md: -------------------------------------------------------------------------------- 1 | # ``ASAudioKit`` 2 | 3 | Summary 4 | 5 | ## Overview 6 | 7 | Text 8 | 9 | ## Topics 10 | 11 | ### Group 12 | 13 | - ``Symbol`` -------------------------------------------------------------------------------- /alsongDalsong/ASAudioKit/ASAudioKit/ASAudioKit.h: -------------------------------------------------------------------------------- 1 | // 2 | // ASAudioKit.h 3 | // ASAudioKit 4 | // 5 | // Created by 박진성 on 11/11/24. 6 | // 7 | 8 | #import 9 | 10 | //! Project version number for ASAudioKit. 11 | FOUNDATION_EXPORT double ASAudioKitVersionNumber; 12 | 13 | //! Project version string for ASAudioKit. 14 | FOUNDATION_EXPORT const unsigned char ASAudioKitVersionString[]; 15 | 16 | // In this header, you should import all the public headers of your framework using statements like #import 17 | 18 | 19 | -------------------------------------------------------------------------------- /alsongDalsong/ASAudioKit/ASAudioKit/ASAudioPlayer/README.md: -------------------------------------------------------------------------------- 1 | # ASAudioPlayer 2 | 3 | ASAudioPlayer 객체 생성 4 | ```swift 5 | let player = ASAudioPlayer() 6 | ``` 7 | 8 | 재생 시작 9 | ```swift 10 | let data: Data = ... 11 | player.startPlaying(data: data, option: .full) 12 | ``` 13 | 14 | 시간 재생 시작 15 | ```swift 16 | player.startPlaying(data: data, option: .partial(6)) 17 | ``` 18 | 19 | -------------------------------------------------------------------------------- /alsongDalsong/ASAudioKit/ASAudioKit/ASAudioRecorder/ASAudioRecorder.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | 3 | public actor ASAudioRecorder { 4 | private var audioRecorder: AVAudioRecorder? 5 | 6 | public init() {} 7 | /// 녹음 후 저장될 파일의 위치를 지정하여 녹음합니다. 8 | public func startRecording(url: URL) { 9 | configureAudioSession() 10 | let settings = [ 11 | AVFormatIDKey: Int(kAudioFormatMPEG4AAC), 12 | AVSampleRateKey: 12000, 13 | AVNumberOfChannelsKey: 1, 14 | AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue, 15 | ] 16 | 17 | do { 18 | audioRecorder = try AVAudioRecorder(url: url, settings: settings) 19 | audioRecorder?.prepareToRecord() 20 | audioRecorder?.isMeteringEnabled = true 21 | audioRecorder?.record() 22 | } catch { 23 | // TODO: AVAudioRecorder 객체 생성 실패 시에 대한 처리 24 | } 25 | } 26 | 27 | /// 오디오 세션을 설정합니다. 28 | private func configureAudioSession() { 29 | do { 30 | let session = AVAudioSession.sharedInstance() 31 | try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) 32 | try session.setActive(true, options: .notifyOthersOnDeactivation) 33 | } catch { 34 | // TODO: 세션 설정 실패에 따른 처리 35 | } 36 | } 37 | 38 | /// 녹음 진행 여부를 확인합니다. 39 | public func isRecording() -> Bool { 40 | if let audioRecorder { 41 | return audioRecorder.isRecording 42 | } 43 | return false 44 | } 45 | 46 | /// 녹음을 중단합니다. 녹음된 파일을 리턴합니다. 47 | @discardableResult 48 | public func stopRecording() -> Data? { 49 | // 녹음 종료 시에 어디 저장되었는지 리턴해줄 필요가 있을 듯, 저장된 녹음파일을 network에 던져줄 필요가 있음. 50 | audioRecorder?.stop() 51 | 52 | guard let recordURL = audioRecorder?.url else { return nil } 53 | 54 | do { 55 | let recordData = try Data(contentsOf: recordURL) 56 | return recordData 57 | } catch { 58 | return nil 59 | } 60 | } 61 | 62 | /// 현재 녹음된 시간을 리턴합니다. 63 | public func getCurrentTime() -> TimeInterval { 64 | return audioRecorder?.currentTime ?? 0 65 | } 66 | 67 | /// recorder의 입력 레벨을 업데이트합니다. 68 | public func updateMeters() { 69 | audioRecorder?.updateMeters() 70 | } 71 | 72 | /// recorder에 입력된 평균 dB을 리턴합니다. 73 | public func getAveragePower() -> Float? { 74 | return audioRecorder?.averagePower(forChannel: 0) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /alsongDalsong/ASAudioKit/ASAudioKit/ASAudioRecorder/README.md: -------------------------------------------------------------------------------- 1 | # ASAudioRecorder 2 | 3 | ASAudioRecorder 객체 생성 4 | ```swift 5 | let recorder = ASAudioRecorder() 6 | ``` 7 | 8 | 녹음 시작 9 | ```swift 10 | let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 11 | .appendingPathComponent("녹음성공테스트") 12 | recorder.startRecording(url: url) 13 | ``` 14 | 15 | 녹음 종료 16 | ```swift 17 | recorder.stopRecording() 18 | ``` 19 | -------------------------------------------------------------------------------- /alsongDalsong/ASAudioKit/ASAudioKitTests/ASAudioPlayerTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | struct ASAudioPlayerTests { 5 | var player: ASAudioPlayer? 6 | var testMusic: Data? 7 | 8 | init() async throws { 9 | player = ASAudioPlayer() 10 | if let url = Bundle.main.url(forResource: "PlayTestDrum", withExtension: "wav") { 11 | do { 12 | testMusic = try Data(contentsOf: url) 13 | } catch { 14 | #expect(false) 15 | } 16 | } 17 | } 18 | 19 | @Test func sta$rtPlaying_성공() async throws { 20 | guard let player, 21 | let testMusic else 22 | { 23 | #expect(false) 24 | return 25 | } 26 | 27 | player.startPlaying(data: testMusic, option: .full) 28 | 29 | #expect(player.isPlaying()) 30 | } 31 | 32 | @Test func 제한된시간동안startPlaying_성공() async throws { 33 | guard let player, 34 | let testMusic else 35 | { 36 | #expect(false) 37 | return 38 | } 39 | 40 | player.startPlaying(data: testMusic, option: .partial(time: 6)) 41 | 42 | DispatchQueue.main.asyncAfter(deadline: .now() + 6.0) { 43 | #expect(player.getCurrentTime() == 6) 44 | #expect(player.isPlaying() == false) 45 | } 46 | 47 | // swiftTesting에서 XCTest에서 비동기 테스트에 사용되는 expectation을 대체할 방법을 못찾겠음 48 | // + 기존 XCTAssertEqual에서 오차값을 설정할 수 있었는데 그런게 보이지 않음. 49 | #expect(player.isPlaying()) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /alsongDalsong/ASAudioKit/ASAudioKitTests/ASAudioRecorderTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | struct ASAudioRecorderTests { 5 | var recorder: ASAudioRecorder? 6 | 7 | init() async throws { 8 | recorder = ASAudioRecorder() 9 | } 10 | 11 | @Test func startRecording_성공() async throws { 12 | let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 13 | .appendingPathComponent("녹음성공테스트") 14 | guard let recorder else { 15 | #expect(false) 16 | return 17 | } 18 | recorder.startRecording(url: url) 19 | #expect(recorder.isRecording()) 20 | } 21 | 22 | @Test func stopRecording_성공() async throws { 23 | let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 24 | .appendingPathComponent("녹음저장성공테스트") 25 | guard let recorder else { 26 | #expect(false) 27 | return 28 | } 29 | recorder.startRecording(url: url) 30 | recorder.stopRecording() 31 | #expect(FileManager.default.fileExists(atPath: url.path)) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /alsongDalsong/ASAudioKit/ASAudioKitTests/TestData/PlayTestDrum.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/ASAudioKit/ASAudioKitTests/TestData/PlayTestDrum.wav -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKit.xcodeproj/xcshareddata/xcschemes/ASCacheKitTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 20 | 26 | 27 | 28 | 29 | 30 | 40 | 41 | 47 | 48 | 50 | 51 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKit/CacheAssembly.swift: -------------------------------------------------------------------------------- 1 | import ASCacheKitProtocol 2 | import ASContainer 3 | 4 | public struct CacheAssembly: Assembly { 5 | public init() {} 6 | 7 | public func assemble(container: Registerable) { 8 | container.registerSingleton(CacheManagerProtocol.self, ASCacheManager()) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKit/Core/Extension/String+Encrypt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CryptoKit 3 | 4 | extension String { 5 | var sha256: String { 6 | let data = Data(utf8) 7 | let hash = SHA256.hash(data: data) 8 | return hash.map { String(format: "%02X", $0) }.joined() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKit/Core/Source/ASCacheManager.swift: -------------------------------------------------------------------------------- 1 | import ASCacheKitProtocol 2 | import Foundation 3 | 4 | public struct ASCacheManager: CacheManagerProtocol, Sendable { 5 | public let memoryCache: MemoryCacheManagerProtocol 6 | public let diskCache: DiskCacheManagerProtocol 7 | 8 | public init() { 9 | memoryCache = MemoryCacheManager() 10 | diskCache = DiskCacheManager() 11 | } 12 | 13 | public init(memoryCache: MemoryCacheManagerProtocol, diskCache: DiskCacheManagerProtocol) { 14 | self.memoryCache = memoryCache 15 | self.diskCache = diskCache 16 | } 17 | 18 | public func loadCache(from url: URL, cacheOption: CacheOption) -> Data? { 19 | let cacheKey = url.absoluteString 20 | return loadData(forKey: cacheKey, cacheOption: cacheOption) 21 | } 22 | 23 | public func saveCache(withKey url: URL, data: Data, cacheOption: CacheOption) { 24 | let cacheKey = url.absoluteString 25 | switch cacheOption { 26 | case .onlyMemory: 27 | memoryCache.setObject(data as NSData, forKey: cacheKey) 28 | case .onlyDisk: 29 | diskCache.saveData(data, forKey: cacheKey) 30 | case .both: 31 | memoryCache.setObject(data as NSData, forKey: cacheKey) 32 | diskCache.saveData(data, forKey: cacheKey) 33 | default: break 34 | } 35 | } 36 | 37 | private func loadData(forKey key: String, cacheOption: CacheOption) -> Data? { 38 | switch cacheOption { 39 | case .onlyMemory: 40 | return loadFromMemory(forKey: key) 41 | case .onlyDisk: 42 | return loadFromDisk(forKey: key) 43 | case .both: 44 | if let cachedData = loadFromMemory(forKey: key) { 45 | return cachedData 46 | } 47 | if let diskData = loadFromDisk(forKey: key) { 48 | return diskData 49 | } 50 | return nil 51 | default: 52 | return nil 53 | } 54 | } 55 | 56 | private func loadFromMemory(forKey key: String) -> Data? { 57 | return memoryCache.getObject(forKey: key) as? Data 58 | } 59 | 60 | private func loadFromDisk(forKey key: String) -> Data? { 61 | if let diskData = diskCache.getData(forKey: key) { 62 | memoryCache.setObject(diskData as NSData, forKey: key) 63 | return diskData 64 | } 65 | return nil 66 | } 67 | 68 | public func clearMemoryCache() { 69 | memoryCache.clearCache() 70 | } 71 | 72 | public func clearDiskCache() { 73 | diskCache.clearCache() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKit/Core/Source/DiskCache.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ASCacheKitProtocol 3 | 4 | struct DiskCacheManager: @unchecked Sendable, DiskCacheManagerProtocol { 5 | private let fileManager = FileManager.default 6 | let cacheDirectory: URL 7 | 8 | init() { 9 | let cachesDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! 10 | cacheDirectory = cachesDirectory.appendingPathComponent("MediaDiskCache") 11 | createCacheDirectory() 12 | } 13 | 14 | private func createCacheDirectory() { 15 | if !fileManager.fileExists(atPath: cacheDirectory.path) { 16 | try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true, attributes: nil) 17 | } 18 | } 19 | 20 | func getData(forKey key: String) -> Data? { 21 | let fileURL = cacheDirectory.appendingPathComponent(key.sha256) 22 | return try? Data(contentsOf: fileURL) 23 | } 24 | 25 | func saveData(_ data: Data, forKey key: String) { 26 | let fileURL = cacheDirectory.appendingPathComponent(key.sha256) 27 | try? data.write(to: fileURL) 28 | } 29 | 30 | func clearCache() { 31 | if let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) { 32 | for fileURL in files { 33 | try? fileManager.removeItem(at: fileURL) 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKit/Core/Source/MemoryCache.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ASCacheKitProtocol 3 | 4 | struct MemoryCacheManager: @unchecked Sendable, MemoryCacheManagerProtocol { 5 | private let cache = NSCache() 6 | 7 | func getObject(forKey key: String) -> AnyObject? { 8 | return cache.object(forKey: key.sha256 as NSString) 9 | } 10 | 11 | func setObject(_ object: AnyObject, forKey key: String) { 12 | cache.setObject(object, forKey: key.sha256 as NSString) 13 | } 14 | 15 | func clearCache() { 16 | cache.removeAllObjects() 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKitDemo/ASCacheKitDemoApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct ASCacheKitDemoApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | ASCacheKitDemoView() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKitDemo/ASCacheKitDemoViewModel.swift: -------------------------------------------------------------------------------- 1 | import ASCacheKit 2 | import ASCacheKitProtocol 3 | import Foundation 4 | 5 | class ASCacheKitDemoViewModel: ObservableObject { 6 | private var cacheManager = ASCacheManager() 7 | private let imageURL = URL(string: "https://picsum.photos/id/13/600/600")! 8 | @Published var imageData: Data? 9 | 10 | @MainActor 11 | func loadCacheData(at cacheOption: CacheOption) { 12 | Task { 13 | imageData = await cacheManager.loadCache(from: imageURL, cacheOption: cacheOption) 14 | } 15 | } 16 | 17 | func clearCache(at cacheOption: CacheOption) { 18 | switch cacheOption { 19 | case .onlyMemory: 20 | cacheManager.clearMemoryCache() 21 | case .onlyDisk: 22 | cacheManager.clearDiskCache() 23 | case .both: 24 | cacheManager.clearMemoryCache() 25 | cacheManager.clearDiskCache() 26 | default: break 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKitDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKitDemo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKitDemo/Toast.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | class ToastViewModel: ObservableObject { 4 | @Published var isPresented: Bool = false 5 | @Published var message: String = "" 6 | 7 | func present(message: String) { 8 | isPresented = true 9 | self.message = message 10 | 11 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 12 | self.isPresented = false 13 | } 14 | } 15 | } 16 | 17 | struct ToastView: View { 18 | @ObservedObject var vm: ToastViewModel 19 | 20 | var body: some View { 21 | ZStack { 22 | RoundedRectangle(cornerRadius: 10) 23 | .fill(Color.teal.opacity(0.2)) 24 | .shadow(radius: 10) 25 | Text(vm.message) 26 | .font(.headline) 27 | .foregroundColor(.black) 28 | .padding() 29 | } 30 | .frame(width: 280, height: 40) 31 | .padding(.bottom, 10) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKitProtocol/CacheManagerProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol CacheManagerProtocol { 4 | func loadCache(from url: URL, cacheOption: CacheOption) -> Data? 5 | func saveCache(withKey url: URL, data: Data, cacheOption: CacheOption) 6 | } 7 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKitProtocol/CacheOption.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum CacheOption { 4 | case onlyMemory 5 | case onlyDisk 6 | case both 7 | case none 8 | } 9 | 10 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKitProtocol/DiskCacheManagerProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol DiskCacheManagerProtocol: Sendable { 4 | func getData(forKey key: String) -> Data? 5 | func saveData(_ data: Data, forKey key: String) 6 | func clearCache() 7 | } 8 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKitProtocol/MemoryCacheManagerProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol MemoryCacheManagerProtocol: Sendable { 4 | func getObject(forKey key: String) -> AnyObject? 5 | func setObject(_ object: AnyObject, forKey key: String) 6 | func clearCache() 7 | } 8 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKitTests/ASCacheKitTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ASCacheKit 2 | import ASCacheKitProtocol 3 | import Testing 4 | import UIKit 5 | 6 | struct ASCacheKitTests { 7 | private var cacheManager = ASCacheManager(memoryCache: MockMemoryCacheManager(), diskCache: MockDiskCacheManager()) 8 | let testData = UIImage(systemName: "star")!.pngData()! 9 | let testImageURL = URL(string: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTUcmUojj8oZ0EzJU027Pul8SpM6ZMxr8HXAgsuunxkFZKSW7K27kLqcsoRaWaEX03kmQg&usqp=CAU")! 10 | 11 | @Test func 디스크에_없는_캐시_로딩() async throws { 12 | let cachedData = await cacheManager.loadCache(from: testImageURL, cacheOption: .onlyDisk) 13 | let diskData = cacheManager.diskCache.getData(forKey: testImageURL.absoluteString) 14 | let memoryData = cacheManager.memoryCache.getObject(forKey: testImageURL.absoluteString) 15 | 16 | #expect(cachedData == nil) 17 | #expect(diskData == nil) 18 | #expect(memoryData == nil) 19 | } 20 | 21 | @Test func 디스크에_있는_캐시_로딩() async throws { 22 | cacheManager.saveCache(withKey: testImageURL, data: testData, cacheOption: .onlyDisk) 23 | let cachedData = await cacheManager.loadCache(from: testImageURL, cacheOption: .onlyDisk) 24 | let diskData = cacheManager.diskCache.getData(forKey: testImageURL.absoluteString) 25 | 26 | #expect(cachedData != nil) 27 | #expect(diskData != nil) 28 | } 29 | 30 | @Test func 메모리에_없는_캐시_로딩() async throws { 31 | cacheManager.saveCache(withKey: testImageURL, data: testData, cacheOption: .onlyDisk) 32 | let memoryData = cacheManager.memoryCache.getObject(forKey: testImageURL.absoluteString) 33 | 34 | #expect(memoryData == nil) 35 | } 36 | 37 | @Test func 메모리에_있는_캐시_로딩() async throws { 38 | cacheManager.saveCache(withKey: testImageURL, data: testData, cacheOption: .onlyMemory) 39 | let cachedData = await cacheManager.loadCache(from: testImageURL, cacheOption: .onlyMemory) 40 | let memoryData = cacheManager.memoryCache.getObject(forKey: testImageURL.absoluteString) 41 | 42 | #expect(cachedData != nil) 43 | #expect(memoryData != nil) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKitTests/Mock/MockDiskCacheManager.swift: -------------------------------------------------------------------------------- 1 | import ASCacheKitProtocol 2 | import Foundation 3 | 4 | final class MockDiskCacheManager: DiskCacheManagerProtocol { 5 | private var mockStorage: [String: Data] = [:] 6 | 7 | func getData(forKey key: String) -> Data? { 8 | return mockStorage[key] 9 | } 10 | 11 | func saveData(_ data: Data, forKey key: String) { 12 | mockStorage[key] = data 13 | } 14 | 15 | func clearCache() { 16 | mockStorage.removeAll() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /alsongDalsong/ASCacheKit/ASCacheKitTests/Mock/MockMemoryCacheManager.swift: -------------------------------------------------------------------------------- 1 | import ASCacheKitProtocol 2 | 3 | final class MockMemoryCacheManager: MemoryCacheManagerProtocol { 4 | private var mockStorage: [String: AnyObject] = [:] 5 | 6 | func getObject(forKey key: String) -> AnyObject? { 7 | return mockStorage[key] 8 | } 9 | 10 | func setObject(_ object: AnyObject, forKey key: String) { 11 | mockStorage[key] = object 12 | } 13 | 14 | func clearCache() { 15 | mockStorage.removeAll() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /alsongDalsong/ASContainer/ASContainer/DIContainer.swift: -------------------------------------------------------------------------------- 1 | public protocol Registerable { 2 | func register(_ type: T.Type, factory: @escaping (Resolvable) -> T) 3 | func register(_ type: T.Type, _ object: T) 4 | func registerSingleton(_ type: T.Type, factory: @escaping (Resolvable) -> T) 5 | func registerSingleton(_ type: T.Type, _ object: T) 6 | } 7 | 8 | public protocol Resolvable { 9 | func resolve(_ type: T.Type) -> T 10 | } 11 | 12 | public protocol Assembly { 13 | func assemble(container: Registerable) 14 | } 15 | 16 | public final class DIContainer: Registerable, Resolvable { 17 | @MainActor public static let shared = DIContainer() 18 | private init() {} 19 | 20 | private var factories = [String: (Resolvable) -> Any]() 21 | private var singletons = [String: Any]() 22 | 23 | public func register(_ type: T.Type, factory: @escaping (Resolvable) -> T) { 24 | let key = "\(type)" 25 | factories[key] = factory 26 | } 27 | 28 | public func register(_ type: T.Type, _ object: T) { 29 | let key = "\(type)" 30 | factories[key] = { _ in object } 31 | } 32 | 33 | public func registerSingleton(_ type: T.Type, factory: @escaping (Resolvable) -> T) { 34 | let key = "\(type)" 35 | factories[key] = { [weak self] resolver in 36 | if let instance = self?.singletons[key] as? T { 37 | return instance 38 | } else { 39 | let instance = factory(resolver) 40 | self?.singletons[key] = instance 41 | return instance 42 | } 43 | } 44 | } 45 | 46 | public func registerSingleton(_ type: T.Type, _ object: T) { 47 | let key = "\(type)" 48 | singletons[key] = object 49 | } 50 | 51 | public func resolve(_ type: T.Type) -> T { 52 | let key = "\(type)" 53 | 54 | if let instance = singletons[key] as? T { 55 | return instance 56 | } 57 | 58 | guard let factory = factories[key] else { 59 | fatalError("등록되지 않은 의존성 타입: \(type)") 60 | } 61 | 62 | guard let dependency = factory(self) as? T else { 63 | fatalError("의존성 팩토리가 \(type) 타입의 인스턴스를 반환하지 않았습니다") 64 | } 65 | 66 | return dependency 67 | } 68 | 69 | public func addAssemblies(_ assemblies: [Assembly]) { 70 | assemblies.forEach { $0.assemble(container: self) } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /alsongDalsong/ASDecoder/ASDecoder.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /alsongDalsong/ASDecoder/ASDecoder.xcodeproj/xcshareddata/xcschemes/ASDecoder.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /alsongDalsong/ASDecoder/ASDecoder.xcodeproj/xcshareddata/xcschemes/ASDecoderTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 20 | 26 | 27 | 28 | 29 | 30 | 40 | 41 | 47 | 48 | 50 | 51 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /alsongDalsong/ASDecoder/ASDecoder/ASDecoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum ASDecoder { 4 | public static func decode(_: T.Type, from data: Data) throws -> T { 5 | let decoder = JSONDecoder() 6 | decoder.keyDecodingStrategy = .convertFromSnakeCase 7 | decoder.dateDecodingStrategy = .iso8601 8 | 9 | return try decoder.decode(T.self, from: data) 10 | } 11 | 12 | public static func handleResponse(result: Result) async throws -> T { 13 | switch result { 14 | case let .success(data): 15 | do { 16 | let decodedData = try decode(T.self, from: data) 17 | return decodedData 18 | } catch { 19 | throw error 20 | } 21 | case let .failure(error): 22 | throw error 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /alsongDalsong/ASDecoder/ASDecoderDemo/ASDecoderDemoApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct ASDecoderDemoApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | ASDecoderDemoView() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /alsongDalsong/ASDecoder/ASDecoderDemo/ASDecoderDemoViewModel.swift: -------------------------------------------------------------------------------- 1 | import ASDecoder 2 | import Foundation 3 | 4 | @MainActor 5 | class ASDecoderDemoViewModel: ObservableObject { 6 | @Published var userInfo: UserInfo? 7 | @Published var errorMessage: String? 8 | @Published var jsonString: String? 9 | 10 | var customDateFormatter: DateFormatter { 11 | let formatter = DateFormatter() 12 | formatter.dateStyle = .medium 13 | return formatter 14 | } 15 | 16 | let modelString = """ 17 | struct UserInfo { 18 | let id: Int 19 | let userName: String 20 | let userAvatarUrl: URL 21 | let userBirthDate: Date 22 | let userStatus: UserStatus 23 | } 24 | """ 25 | 26 | func loadUserInfo(from scenario: Scenarios) async { 27 | jsonString = scenario.string 28 | let result: Result = .success(scenario.data) 29 | 30 | do { 31 | userInfo = try await ASDecoder.handleResponse(result: result) 32 | } catch { 33 | userInfo = nil 34 | errorMessage = "디코딩 실패: \(error.localizedDescription)" 35 | } 36 | } 37 | 38 | func reset() { 39 | userInfo = nil 40 | errorMessage = nil 41 | jsonString = nil 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /alsongDalsong/ASDecoder/ASDecoderDemo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /alsongDalsong/ASDecoder/ASDecoderDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /alsongDalsong/ASDecoder/ASDecoderDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /alsongDalsong/ASDecoder/ASDecoderDemo/DemoModels.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct UserInfo: Decodable, Identifiable { 4 | let id: Int 5 | let userName: String 6 | let userAvatarUrl: URL 7 | let userBirthDate: Date 8 | let userStatus: UserStatus 9 | } 10 | 11 | enum UserStatus: String, Decodable { 12 | case waiting 13 | case humming 14 | } 15 | 16 | enum Scenarios { 17 | case correct 18 | case missing 19 | case incorrect 20 | 21 | var string: String? { 22 | switch self { 23 | case .correct: return String(data: JSONDataScenarios.correctData, encoding: .utf8) 24 | case .missing: return String(data: JSONDataScenarios.missingFieldData, encoding: .utf8) 25 | case .incorrect: return String(data: JSONDataScenarios.incorrectFormatData, encoding: .utf8) 26 | } 27 | } 28 | 29 | var data: Data { 30 | switch self { 31 | case .correct: return JSONDataScenarios.correctData 32 | case .missing: return JSONDataScenarios.missingFieldData 33 | case .incorrect: return JSONDataScenarios.incorrectFormatData 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /alsongDalsong/ASDecoder/ASDecoderDemo/JSONDataScenarios.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct JSONDataScenarios { 4 | static let correctData = """ 5 | { 6 | "id": 1, 7 | "user_name": "아이유", 8 | "user_avatar_url": "https://www.apple.com", 9 | "user_birth_date": "2024-10-20T10:00:00Z", 10 | "user_status": "waiting" 11 | } 12 | """.data(using: .utf8)! 13 | 14 | static let missingFieldData = """ 15 | { 16 | "id": 1, 17 | "user_name": "에스파", 18 | "user_avatar_url": "https://www.apple.com" 19 | } 20 | """.data(using: .utf8)! 21 | 22 | static let incorrectFormatData = """ 23 | { 24 | "id": "one", 25 | "user_name": "뉴진스", 26 | "user_avatar_url": "https://www.apple.com", 27 | "user_birth_date": "2024-10-20T10:00:00Z", 28 | "user_status": "humming" 29 | } 30 | """.data(using: .utf8)! 31 | } 32 | -------------------------------------------------------------------------------- /alsongDalsong/ASDecoder/ASDecoderDemo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /alsongDalsong/ASDecoder/ASDecoderDemo/ViewModifiers.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PrimaryGradientBackgroundModifier: ViewModifier { 4 | var textColor: Color 5 | 6 | func body(content: Content) -> some View { 7 | content 8 | .foregroundColor(textColor) 9 | .padding() 10 | .background(LinearGradient(gradient: Gradient(colors: [.cyan, .green, .yellow]), startPoint: .topLeading, endPoint: .bottomTrailing)) 11 | .clipShape(RoundedRectangle(cornerRadius: 10)) 12 | } 13 | } 14 | 15 | struct SecondaryGradientBackgroundModifier: ViewModifier { 16 | var textColor: Color 17 | 18 | func body(content: Content) -> some View { 19 | content 20 | .foregroundColor(textColor) 21 | .padding() 22 | .background(LinearGradient(gradient: Gradient(colors: [.red, .purple, .yellow]), startPoint: .topLeading, endPoint: .bottomTrailing)) 23 | .clipShape(RoundedRectangle(cornerRadius: 10)) 24 | } 25 | } 26 | 27 | struct GrayBackgroundModifier: ViewModifier { 28 | var textColor: Color 29 | 30 | func body(content: Content) -> some View { 31 | content 32 | .foregroundColor(textColor) 33 | .padding() 34 | .background(Color.gray) 35 | .clipShape(RoundedRectangle(cornerRadius: 10)) 36 | } 37 | } 38 | 39 | extension View { 40 | func primaryGradientBackground(textColor: Color = .black) -> some View { 41 | modifier(PrimaryGradientBackgroundModifier(textColor: textColor)) 42 | } 43 | 44 | func secondaryGradientBackground(textColor: Color = .white) -> some View { 45 | modifier(SecondaryGradientBackgroundModifier(textColor: textColor)) 46 | } 47 | 48 | func grayBackground(textColor: Color = .white) -> some View { 49 | modifier(GrayBackgroundModifier(textColor: textColor)) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /alsongDalsong/ASDecoder/ASDecoderTests/ASDecoderTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ASDecoder 2 | import Foundation 3 | import Testing 4 | 5 | struct ASDecoderTests { 6 | struct SampleData: Decodable, Equatable { 7 | let id: Int 8 | let name: String 9 | } 10 | 11 | @Test func 디코딩_성공() async throws { 12 | let jsonString = """ 13 | { 14 | "id": 1, 15 | "name": "아이유" 16 | } 17 | """ 18 | let jsonData = jsonString.data(using: .utf8)! 19 | let result: Result = .success(jsonData) 20 | 21 | let decodedData: SampleData = try await ASDecoder.handleResponse(result: result) 22 | #expect(decodedData == SampleData(id: 1, name: "아이유")) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /alsongDalsong/ASEncoder/ASEncoder.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /alsongDalsong/ASEncoder/ASEncoder.xcodeproj/xcshareddata/xcschemes/ASEncoderTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 20 | 26 | 27 | 28 | 29 | 30 | 40 | 41 | 47 | 48 | 50 | 51 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /alsongDalsong/ASEncoder/ASEncoder/ASEncoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum ASEncoder { 4 | public static func encode(_ value: T) throws -> Data { 5 | let encoder = JSONEncoder() 6 | encoder.dateEncodingStrategy = .iso8601 7 | encoder.keyEncodingStrategy = .useDefaultKeys 8 | encoder.outputFormatting = [.prettyPrinted] 9 | 10 | return try encoder.encode(value) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /alsongDalsong/ASEncoder/ASEncoderDemo/ASEncoderDemoApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct ASEncoderDemoApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | EncodingDemoView() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /alsongDalsong/ASEncoder/ASEncoderDemo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /alsongDalsong/ASEncoder/ASEncoderDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /alsongDalsong/ASEncoder/ASEncoderDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /alsongDalsong/ASEncoder/ASEncoderDemo/DemoModels.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class UserInfo: Encodable { 4 | var userName: String 5 | var userAvaterUrl: URL 6 | var userBirthDate: Date 7 | var userStatus: UserStatus 8 | 9 | init(userName: String, userAvaterUrl: URL, userBirthDate: Date, userStatus: UserStatus) { 10 | self.userName = userName 11 | self.userAvaterUrl = userAvaterUrl 12 | self.userBirthDate = userBirthDate 13 | self.userStatus = userStatus 14 | } 15 | } 16 | 17 | enum UserStatus: String, CaseIterable, Identifiable, Encodable { 18 | case waiting 19 | case humming 20 | var id: String { self.rawValue } 21 | } 22 | -------------------------------------------------------------------------------- /alsongDalsong/ASEncoder/ASEncoderDemo/EncodingDemoView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct EncodingDemoView: View { 4 | @ObservedObject var viewModel = EncodingDemoViewModel() 5 | 6 | var body: some View { 7 | NavigationStack { 8 | VStack { 9 | List { 10 | Section(header: Text("사용자 이름")) { 11 | TextField("이름을 입력하세요", text: $viewModel.userInfo.userName) 12 | } 13 | 14 | Section(header: Text("아바타 URL")) { 15 | TextField("URL을 입력하세요", text: $viewModel.userAvatarUrl) 16 | .keyboardType(.URL) 17 | .textInputAutocapitalization(.never) 18 | } 19 | 20 | Section(header: Text("생년월일")) { 21 | DatePicker( 22 | "생년월일", 23 | selection: $viewModel.userInfo.userBirthDate, 24 | displayedComponents: .date 25 | ) 26 | } 27 | 28 | Section(header: Text("사용자 상태")) { 29 | Picker("상태", selection: $viewModel.userInfo.userStatus) { 30 | ForEach(UserStatus.allCases) { status in 31 | Text(status.rawValue).tag(status) 32 | } 33 | } 34 | } 35 | if let jsonDisplayText = viewModel.jsonDisplayText { 36 | Text(jsonDisplayText) 37 | .padding() 38 | .listRowSeparator(.hidden) 39 | .listRowInsets(.none) 40 | .listRowBackground( 41 | Color(uiColor: .systemGroupedBackground) 42 | .clipShape(RoundedRectangle(cornerRadius: 25)) 43 | .padding() 44 | ) 45 | } 46 | } 47 | .listStyle(.plain) 48 | } 49 | .onReceive(viewModel.$userInfo) { _ in 50 | viewModel.updateJSON() 51 | } 52 | .navigationTitle("Encoding Demo") 53 | .navigationBarTitleDisplayMode(.inline) 54 | } 55 | } 56 | } 57 | 58 | #Preview { 59 | EncodingDemoView() 60 | } 61 | -------------------------------------------------------------------------------- /alsongDalsong/ASEncoder/ASEncoderDemo/EncodingDemoViewModel.swift: -------------------------------------------------------------------------------- 1 | import ASEncoder 2 | import Foundation 3 | 4 | public class EncodingDemoViewModel: ObservableObject { 5 | @Published var userInfo: UserInfo = .init( 6 | userName: "Rosè", 7 | userAvaterUrl: URL(string: "www.apple.com")!, 8 | userBirthDate: .now, 9 | userStatus: .humming 10 | ) 11 | 12 | @Published var encodedData: Data? 13 | @Published var jsonDisplayText: String? 14 | 15 | var userAvatarUrl: String { 16 | get { userInfo.userAvaterUrl.absoluteString } 17 | set { userInfo.userAvaterUrl = URL(string: newValue) ?? userInfo.userAvaterUrl 18 | updateJSON() 19 | } 20 | } 21 | 22 | func updateJSON() { 23 | encodedData = try? ASEncoder.encode(userInfo) 24 | jsonDisplayText = convertToPrettyJSON(from: encodedData) 25 | } 26 | 27 | private func convertToPrettyJSON(from data: Data?) -> String? { 28 | guard let data = data else { return nil } 29 | do { 30 | let jsonObject = try JSONSerialization.jsonObject(with: data) 31 | let prettyData = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted]) 32 | return String(data: prettyData, encoding: .utf8) 33 | } catch { 34 | print("JSON formatting failed:", error) 35 | return nil 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /alsongDalsong/ASEncoder/ASEncoderDemo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /alsongDalsong/ASEncoder/ASEncoderTests/ASEncoderTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ASEncoder 2 | import Foundation 3 | import Testing 4 | 5 | struct ASEncoderTests { 6 | struct TestModel: Encodable, Equatable { 7 | let name: String 8 | let age: Int 9 | let birthDate: Date 10 | } 11 | 12 | @Test func testmodel_인코딩() async throws { 13 | let dateFormatter = ISO8601DateFormatter() 14 | let dateString = "2000-01-01T00:00:00Z" 15 | let date = dateFormatter.date(from: dateString)! 16 | let testModel = TestModel(name: "아이유", age: 30, birthDate: date) 17 | let jsonData = try ASEncoder.encode(testModel) 18 | #expect(jsonData != nil) 19 | 20 | let json = try JSONSerialization.jsonObject(with: jsonData, options: []) 21 | #expect(json is [String: Any]) 22 | 23 | let dictionary = json as! [String: Any] 24 | #expect(dictionary["name"] as? String == "아이유") 25 | #expect(dictionary["age"] as? Int == 30) 26 | #expect(dictionary["birthDate"] as? String == dateString) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /alsongDalsong/ASEntity/ASEntity.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /alsongDalsong/ASEntity/ASEntity/Answer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Answer: Codable, Equatable, Sendable, Hashable { 4 | public var player: Player? 5 | public var music: Music? 6 | public var playlist: Playlist? 7 | 8 | public init(player: Player?, music: Music?, playlist: Playlist?) { 9 | self.player = player 10 | self.music = music 11 | self.playlist = playlist 12 | } 13 | } 14 | 15 | extension Answer { 16 | public static let answerStub1 = Answer(player: Player.playerStub1, 17 | music: Music.musicStub1, 18 | playlist: Playlist()) 19 | public static let answerStub2 = Answer(player: Player.playerStub2, 20 | music: Music.musicStub2, 21 | playlist: Playlist()) 22 | public static let answerStub3 = Answer(player: Player.playerStub3, 23 | music: Music.musicStub3, 24 | playlist: Playlist()) 25 | public static let answerStub4 = Answer(player: Player.playerStub4, 26 | music: Music.musicStub4, 27 | playlist: Playlist()) 28 | } 29 | -------------------------------------------------------------------------------- /alsongDalsong/ASEntity/ASEntity/Mode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Mode: String, Codable, CaseIterable, Identifiable { 4 | case humming 5 | case harmony 6 | case sync 7 | case instant 8 | case tts 9 | 10 | public var id: String { rawValue } 11 | 12 | public var Index: Int { 13 | switch self { 14 | case .humming: 1 15 | case .harmony: 2 16 | case .sync: 3 17 | case .instant: 4 18 | case .tts: 5 19 | } 20 | } 21 | 22 | public static func fromIndex(_ index: Int) -> Mode? { 23 | switch index { 24 | case 1: return .humming 25 | case 2: return .harmony 26 | case 3: return .sync 27 | case 4: return .instant 28 | case 5: return .tts 29 | default: return nil 30 | } 31 | } 32 | 33 | public var title: String { 34 | switch self { 35 | case .humming: return "허밍" 36 | case .harmony: return "하모니" 37 | case .sync: return "이구동성" 38 | case .instant: return "찰나의순간" 39 | case .tts: return "TTS" 40 | } 41 | } 42 | 43 | public var description: String { 44 | switch self { 45 | case .humming: return "원하는 노래를 선택하고 허밍을 하세요! 다음 사람부터 당신의 허밍을 따라하게 됩니다. 마지막 친구는 허밍을 듣고 어떤 노래인지 맞출 수 있을까요?" 46 | case .harmony: return "각 플레이어는 하나의 파트를 녹음하고, 그 녹음을 합쳐 완벽한 하모니를 만들어야 합니다. 플레이어들이 녹음한 각각의 음성을 합치면서, 누구의 파트가 가장 잘 어우러지는지 확인하세요" 47 | case .sync: return "동시에 부르는 음악을 맞추는 모드입니다. " 48 | case .instant: return "1초 듣고 맞추기 모드는 짧은 시간에 최대한의 집중을 요구하는 모드입니다. 1초동안 랜덤으로 선택된 노래 클립을 듣고, 무엇인지 맞춰야 합니다. 짧은 시간에 어떤 노래인지 맞출 수 있을까요?" 49 | case .tts: return "음악의 가사만 듣고 노래를 맞추는 모드입니다. 선택된 노래의 가사를 TTS로 읽어주며, 플레이어는 그 가사에 해당하는 노래를 맞춰야 합니다." 50 | } 51 | } 52 | 53 | public var imageName: String { 54 | switch self { 55 | case .humming: return "humming" 56 | case .harmony: return "harmony" 57 | case .sync: return "sync" 58 | case .instant: return "instant" 59 | case .tts: return "tts" 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /alsongDalsong/ASEntity/ASEntity/Music.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Music: Codable, Equatable, Identifiable, Sendable, Hashable { 4 | public var id: String? 5 | public var title: String? 6 | public var artist: String? 7 | public var artworkUrl: URL? 8 | public var previewUrl: URL? 9 | public var lyrics: String? 10 | public var artworkBackgroundColor: String? 11 | 12 | public init() {} 13 | 14 | public init(title: String, artist: String) { 15 | self.title = title 16 | self.artist = artist 17 | } 18 | 19 | public init(id: String, title: String?, artist: String?, artworkUrl: URL?, previewUrl: URL?, artworkBackgroundColor: String?) { 20 | self.id = id 21 | self.title = title 22 | self.artist = artist 23 | self.artworkUrl = artworkUrl 24 | self.previewUrl = previewUrl 25 | self.artworkBackgroundColor = artworkBackgroundColor 26 | } 27 | } 28 | 29 | extension Music { 30 | public init(_ record: ASEntity.Record) { 31 | self.previewUrl = record.fileUrl 32 | } 33 | } 34 | 35 | extension Music { 36 | public static let musicStub1 = Music(title: "네 번호가 뜨는 일", artist: "이예준") 37 | public static let musicStub2 = Music(title: "그거 아세요?", artist: "이혁") 38 | public static let musicStub3 = Music(title: "으아~", artist: "김흥국") 39 | public static let musicStub4 = Music(title: "이브, 프시케 그리고 푸른 수염의 아내", artist: "르세라핌") 40 | } 41 | -------------------------------------------------------------------------------- /alsongDalsong/ASEntity/ASEntity/Player.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Player: Codable, Equatable, Identifiable, Sendable, Hashable { 4 | public var id: String 5 | public var avatarUrl: URL? 6 | public var nickname: String? 7 | public var score: Int? 8 | public var order: Int? 9 | 10 | public init( 11 | id: String, 12 | avatarUrl: URL? = nil, 13 | nickname: String? = nil, 14 | score: Int? = nil, 15 | order: Int? = nil 16 | ) { 17 | self.id = id 18 | self.avatarUrl = avatarUrl 19 | self.nickname = nickname 20 | self.score = score 21 | self.order = order 22 | } 23 | } 24 | 25 | extension Player { 26 | public static let playerStub1: Player = Player(id: "0", avatarUrl: nil, nickname: "Tltlbo", score: nil, order: 0) 27 | public static let playerStub2: Player = Player(id: "1", avatarUrl: nil, nickname: "Sonny", score: nil, order: 1) 28 | public static let playerStub3: Player = Player(id: "2", avatarUrl: nil, nickname: "Moral-life", score: nil, order: 2) 29 | public static let playerStub4: Player = Player(id: "3", avatarUrl: nil, nickname: "Sang₩", score: nil, order: 3) 30 | } 31 | -------------------------------------------------------------------------------- /alsongDalsong/ASEntity/ASEntity/Playlist.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Playlist: Codable, Equatable, Sendable, Hashable { 4 | public var artworkUrl: URL? 5 | public var title: String? 6 | 7 | public init() {} 8 | } 9 | -------------------------------------------------------------------------------- /alsongDalsong/ASEntity/ASEntity/Record.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Record: Codable, Equatable, Sendable, Hashable { 4 | public var player: Player? 5 | public var recordOrder: UInt8? 6 | public var fileUrl: URL? 7 | 8 | public init(player: Player? = nil, recordOrder: UInt8? = nil, fileUrl: URL? = nil) { 9 | self.player = player 10 | self.recordOrder = recordOrder 11 | self.fileUrl = fileUrl 12 | } 13 | } 14 | 15 | extension Record { 16 | private static let stubm4aData: URL? = { 17 | return URL(string: "https://firebasestorage.googleapis.com/v0/b/alsongdalsong-boostcamp.firebasestorage.app/o/audios%2Ffeef1adc-d0aa-4af7-b2c6-16f2dda339e9_mzaf_16018936309126267135.plus.aac.p.m4a?alt=media&token=60d59d23-a9f8-4ac6-ae85-5f6824f6e39a") 18 | }() 19 | 20 | public static let recordStub1_1 = Record(player: Player.playerStub1, recordOrder: 0, fileUrl: stubm4aData) 21 | public static let recordStub1_2 = Record(player: Player.playerStub1, recordOrder: 1, fileUrl: stubm4aData) 22 | public static let recordStub1_3 = Record(player: Player.playerStub1, recordOrder: 2, fileUrl: stubm4aData) 23 | public static let recordStub2_1 = Record(player: Player.playerStub2, recordOrder: 0, fileUrl: stubm4aData) 24 | public static let recordStub2_2 = Record(player: Player.playerStub2, recordOrder: 1, fileUrl: stubm4aData) 25 | public static let recordStub2_3 = Record(player: Player.playerStub2, recordOrder: 2, fileUrl: stubm4aData) 26 | public static let recordStub3_1 = Record(player: Player.playerStub3, recordOrder: 0, fileUrl: stubm4aData) 27 | public static let recordStub3_2 = Record(player: Player.playerStub3, recordOrder: 1, fileUrl: stubm4aData) 28 | public static let recordStub3_3 = Record(player: Player.playerStub3, recordOrder: 2, fileUrl: stubm4aData) 29 | public static let recordStub4_1 = Record(player: Player.playerStub4, recordOrder: 0, fileUrl: stubm4aData) 30 | public static let recordStub4_2 = Record(player: Player.playerStub4, recordOrder: 1, fileUrl: stubm4aData) 31 | public static let recordStub4_3 = Record(player: Player.playerStub4, recordOrder: 2, fileUrl: stubm4aData) 32 | } 33 | -------------------------------------------------------------------------------- /alsongDalsong/ASEntity/ASEntity/Room.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Room: Codable { 4 | public var number: String? 5 | public var host: Player? 6 | public var players: [Player]? 7 | public var mode: Mode? 8 | public var round: UInt8? 9 | public var status: Status? 10 | public var recordOrder: UInt8? 11 | public var records: [Record]? 12 | public var answers: [Answer]? 13 | public var dueTime: Date? 14 | public var selectedRecords: [UInt8]? 15 | public var submits: [Answer]? 16 | 17 | public init( 18 | number: String? = nil, 19 | host: Player? = nil, 20 | players: [Player]? = nil, 21 | mode: Mode? = nil, 22 | round: UInt8? = nil, 23 | status: Status? = nil, 24 | recordOrder: UInt8? = nil, 25 | records: [Record]? = nil, 26 | answers: [Answer]? = nil, 27 | dueTime: Date? = nil, 28 | selectedRecords: [UInt8]? = nil, 29 | submits: [Answer]? = nil 30 | ) { 31 | self.number = number 32 | self.host = host 33 | self.players = players 34 | self.mode = mode 35 | self.round = round 36 | self.status = status 37 | self.recordOrder = recordOrder 38 | self.records = records 39 | self.answers = answers 40 | self.dueTime = dueTime 41 | self.selectedRecords = selectedRecords 42 | self.submits = submits 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /alsongDalsong/ASEntity/ASEntity/Status.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Status: String, Codable { 4 | case humming 5 | case rehumming 6 | case waiting 7 | case hint 8 | case result 9 | } 10 | -------------------------------------------------------------------------------- /alsongDalsong/ASLogKit/ASLogKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /alsongDalsong/ASMusicKit/ASMusicKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /alsongDalsong/ASMusicKit/ASMusicKit/ASMusicKit.docc/ASMusicKit.md: -------------------------------------------------------------------------------- 1 | # ``ASMusicKit`` 2 | 3 | Summary 4 | 5 | ## Overview 6 | 7 | Text 8 | 9 | ## Topics 10 | 11 | ### Group 12 | 13 | - ``Symbol`` -------------------------------------------------------------------------------- /alsongDalsong/ASMusicKit/ASMusicKit/CGColor+Hex.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | 3 | extension CGColor { 4 | func toHex() -> String? { 5 | guard let components = components, components.count >= 3 else { 6 | return nil 7 | } 8 | let r = components[0] 9 | let g = components[1] 10 | let b = components[2] 11 | 12 | var a: CGFloat = 1.0 13 | if components.count >= 4 { 14 | a = components[3] 15 | } 16 | 17 | if a != 1.0 { 18 | return String(format: "#%02X%02X%02X%02X", 19 | Int(r * 255), 20 | Int(g * 255), 21 | Int(b * 255), 22 | Int(a * 255)) 23 | } else { 24 | return String(format: "#%02X%02X%02X", 25 | Int(r * 255), 26 | Int(g * 255), 27 | Int(b * 255)) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASFirebaseDemo/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SERVER_URL 6 | $(SERVER_URL) 7 | UIApplicationSceneManifest 8 | 9 | UIApplicationSupportsMultipleScenes 10 | 11 | UISceneConfigurations 12 | 13 | UIWindowSceneSessionRoleApplication 14 | 15 | 16 | UISceneConfigurationName 17 | Default Configuration 18 | UISceneDelegateClassName 19 | $(PRODUCT_MODULE_NAME).SceneDelegate 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASFirebaseDemo/Sources/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @main 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | func application( 6 | _ application: UIApplication, 7 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 8 | ) -> Bool { 9 | true 10 | } 11 | 12 | func application( 13 | _ application: UIApplication, 14 | configurationForConnecting connectingSceneSession: UISceneSession, 15 | options: UIScene.ConnectionOptions 16 | ) -> UISceneConfiguration { 17 | UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASFirebaseDemo/Sources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASFirebaseDemo/Sources/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import Firebase 2 | import UIKit 3 | 4 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 5 | var window: UIWindow? 6 | 7 | func scene(_ scene: UIScene, 8 | willConnectTo _: UISceneSession, 9 | options _: UIScene.ConnectionOptions) { 10 | guard let windowScene = (scene as? UIWindowScene) else { return } 11 | FirebaseApp.configure() 12 | window = UIWindow(windowScene: windowScene) 13 | let viewController = MainViewController() 14 | let navigationController = UINavigationController(rootViewController: viewController) 15 | window?.rootViewController = navigationController 16 | window?.makeKeyAndVisible() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/ASNetworkErrors.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum ASNetworkErrors: Error, LocalizedError { 4 | case serverError(message: String) 5 | case urlError 6 | case responseError 7 | case FirebaseSignInError 8 | case FirebaseSignOutError 9 | case FirebaseListenerError 10 | 11 | public var errorDescription: String? { 12 | switch self { 13 | case let .serverError(message: message): 14 | return message 15 | case .urlError: 16 | return "URL에러: URL이 제대로 입력되지 않았습니다." 17 | case .responseError: 18 | return "응답 에러: 서버에서 응답이 없거나 잘못된 응답이 왔습니다." 19 | case .FirebaseSignInError: 20 | return "파이어베이스 에러: 익명 로그인에 실패했습니다." 21 | case .FirebaseSignOutError: 22 | return "파이어베이스 에러: 로그아웃에 실패했습니다." 23 | case .FirebaseListenerError: 24 | return "파이어베이스 에러: 해당 데이터베이스를 가져오는데 실패했습니다." 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/Endpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol Endpoint { 4 | associatedtype Path: CustomStringConvertible 5 | var scheme: String { get } 6 | var host: String { get } 7 | var path: Path { get set } 8 | var method: HTTPMethod { get set } 9 | var headers: [String: String] { get set } 10 | var body: Data? { get set } 11 | var queryItems: [URLQueryItem]? { get set } 12 | var url: URL? { get } 13 | func update(_: WritableKeyPath, with _: T) -> Self 14 | } 15 | 16 | extension Endpoint { 17 | public var url: URL? { 18 | var components = URLComponents() 19 | components.scheme = scheme 20 | components.host = "\(path.description.dropFirst())-\(host)" 21 | components.path = path.description 22 | components.queryItems = queryItems 23 | return components.url 24 | } 25 | 26 | public func update(_ keyPath: WritableKeyPath, with value: T) -> Self { 27 | var copy = self 28 | copy[keyPath: keyPath] = value 29 | return copy 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/Firebase/ASFirebaseAuth.swift: -------------------------------------------------------------------------------- 1 | import ASEncoder 2 | import ASEntity 3 | import Foundation 4 | @preconcurrency internal import FirebaseAuth 5 | @preconcurrency internal import FirebaseDatabase 6 | 7 | public final class ASFirebaseAuth: ASFirebaseAuthProtocol { 8 | public static var myID: String? 9 | private let databaseRef = Database.database().reference() 10 | 11 | public func signIn(nickname: String, avatarURL: URL?) async throws { 12 | do { 13 | guard let myID = ASFirebaseAuth.myID else { throw ASNetworkErrors.FirebaseSignInError } 14 | let player = Player(id: myID, avatarUrl: avatarURL, nickname: nickname, score: 0, order: 0) 15 | let playerData = try ASEncoder.encode(player) 16 | let dict = try JSONSerialization.jsonObject(with: playerData, options: .allowFragments) as? [String: Any] 17 | let userStatusRef = databaseRef.child("players").child(myID) 18 | userStatusRef.keepSynced(true) 19 | let connectedRef = databaseRef.child(".info/connected") 20 | connectedRef.observe(.value) { snapshot in 21 | guard let isConnected = snapshot.value as? Bool else { return } 22 | if isConnected { 23 | userStatusRef.setValue(dict) 24 | } 25 | } 26 | } catch { 27 | throw ASNetworkErrors.FirebaseSignInError 28 | } 29 | } 30 | 31 | public func signOut() async throws { 32 | do { 33 | guard let userID = ASFirebaseAuth.myID else { throw ASNetworkErrors.FirebaseSignOutError } 34 | try await databaseRef.child("players").child(userID).removeValue() 35 | try Auth.auth().signOut() 36 | } catch { 37 | throw ASNetworkErrors.FirebaseSignOutError 38 | } 39 | } 40 | 41 | public static func configure() { 42 | if let uid = Auth.auth().currentUser?.uid { 43 | ASFirebaseAuth.myID = uid 44 | } else { 45 | Task { 46 | let authResult = try await Auth.auth().signInAnonymously() 47 | ASFirebaseAuth.myID = authResult.user.uid 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/Firebase/ASFirebaseDatabase.swift: -------------------------------------------------------------------------------- 1 | import ASEntity 2 | import Combine 3 | @preconcurrency internal import FirebaseFirestore 4 | 5 | public final class ASFirebaseDatabase: ASFirebaseDatabaseProtocol { 6 | private let firestoreRef = Firestore.firestore() 7 | private var roomListeners: ListenerRegistration? 8 | private var roomPublisher = PassthroughSubject() 9 | 10 | public func addRoomListener(roomNumber: String) -> AnyPublisher { 11 | let roomRef = firestoreRef.collection("rooms").document(roomNumber) 12 | let listener = roomRef.addSnapshotListener { documentSnapshot, error in 13 | if let error { 14 | return self.roomPublisher.send(completion: .failure(error)) 15 | } 16 | 17 | guard let document = documentSnapshot, document.exists else { 18 | return self.roomPublisher.send(completion: .failure(ASNetworkErrors.FirebaseListenerError)) 19 | } 20 | 21 | do { 22 | let room = try document.data(as: Room.self) 23 | return self.roomPublisher.send(room) 24 | } catch { 25 | return self.roomPublisher.send(completion: .failure(ASNetworkErrors.FirebaseListenerError)) 26 | } 27 | } 28 | 29 | roomListeners = listener 30 | return roomPublisher.eraseToAnyPublisher() 31 | } 32 | 33 | public func removeRoomListener() { 34 | roomListeners?.remove() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/Firebase/ASFirebaseStorage.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency internal import FirebaseStorage 2 | import Foundation 3 | 4 | public final class ASFirebaseStorage: ASFirebaseStorageProtocol { 5 | private let storageRef = Storage.storage().reference() 6 | 7 | public func getAvatarUrls() async throws -> [URL] { 8 | let avatarRef = storageRef.child("avatar") 9 | do { 10 | let result = try await avatarRef.listAll() 11 | return try await fetchDownloadURLs(from: result.items) 12 | } catch { 13 | throw ASNetworkErrors.responseError 14 | } 15 | } 16 | 17 | private func fetchDownloadURLs(from items: [StorageReference]) async throws -> [URL] { 18 | try await withThrowingTaskGroup(of: URL.self) { taskGroup in 19 | for item in items { 20 | taskGroup.addTask { 21 | try await self.downloadURL(for: item) 22 | } 23 | } 24 | 25 | return try await taskGroup.reduce(into: []) { urls, url in 26 | urls.append(url) 27 | } 28 | } 29 | } 30 | 31 | private func downloadURL(for item: StorageReference) async throws -> URL { 32 | try await withCheckedThrowingContinuation { continuation in 33 | item.downloadURL { url, error in 34 | if let url = url { 35 | continuation.resume(returning: url) 36 | } else if let error = error { 37 | continuation.resume(throwing: error) 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/FirebaseEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct FirebaseEndpoint: Endpoint, Equatable { 4 | public let scheme: String = "https" 5 | // TODO: - firebase api에 맞는 host 넣기 6 | public let host: String = Bundle.main.object(forInfoDictionaryKey: "SERVER_URL") as! String 7 | public var path: Path 8 | public var method: HTTPMethod 9 | // 헤더는 기본적인 것을 넣어두고 필요할 때만 추가하는 게 좋을듯 10 | public var headers: [String: String] 11 | public var body: Data? 12 | public var queryItems: [URLQueryItem]? 13 | 14 | public init(path: Path, method: HTTPMethod) { 15 | self.path = path 16 | self.method = method 17 | headers = [:] 18 | } 19 | 20 | // TODO: - firebase api/cloud func에 맞는 path 넣기 21 | public enum Path: CustomStringConvertible { 22 | case auth 23 | case createRoom 24 | case joinRoom 25 | case gameStart 26 | case changeMode 27 | case uploadRecording 28 | case changeRecordOrder 29 | case submitMusic 30 | case submitAnswer 31 | case resetGame 32 | 33 | public var description: String { 34 | switch self { 35 | case .auth: 36 | "/auth" 37 | case .createRoom: 38 | "/createRoom" 39 | case .joinRoom: 40 | "/joinRoom" 41 | case .gameStart: 42 | "/startGame" 43 | case .changeMode: 44 | "/changeMode" 45 | case .uploadRecording: 46 | "/v2-uploadRecording" 47 | case .submitMusic: 48 | "/v2-submitMusic" 49 | case .submitAnswer: 50 | "/v2-submitAnswer" 51 | case .changeRecordOrder: 52 | "/changeRecordOrder" 53 | case .resetGame: 54 | "/resetGame" 55 | } 56 | } 57 | } 58 | } 59 | 60 | // MARK: - example functions 61 | 62 | public extension FirebaseEndpoint { 63 | func test(body: Data) -> any Endpoint { 64 | Self(path: .auth, method: .get) 65 | .update(\.body, with: body) 66 | } 67 | 68 | func fetchAllAvatarURLs() -> any Endpoint { 69 | Self(path: .auth, method: .get) 70 | .update(\.queryItems, with: [.init(name: "listAvatarUrls", value: "true")]) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/HTTPContentType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum HTTPContentType { 4 | case none 5 | case json 6 | case multipart 7 | 8 | func header(_ boundary: String) -> [String: String] { 9 | switch self { 10 | case .json: 11 | return ["Content-Type": "application/json"] 12 | case .multipart: 13 | let boundary = "Boundary-\(boundary)" 14 | return ["Content-Type": "multipart/form-data; boundary=\(boundary)"] 15 | case .none: 16 | return [:] 17 | } 18 | } 19 | 20 | func body(_ boundary: String, with data: Data?) -> Data? { 21 | switch self { 22 | case .json: 23 | return data 24 | case .multipart: 25 | var body = Data() 26 | let boundary = "Boundary-\(boundary)" 27 | body.append("--\(boundary)\r\n".data(using: .utf8)!) 28 | body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(UUID().uuidString).m4a\"\r\n".data(using: .utf8)!) 29 | body.append("Content-Type: audio/m4a\r\n\r\n".data(using: .utf8)!) 30 | body.append(data!) 31 | body.append("\r\n".data(using: .utf8)!) 32 | body.append("--\(boundary)--\r\n".data(using: .utf8)!) 33 | return body 34 | case .none: 35 | return nil 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/HTTPMethod.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum HTTPMethod: String { 4 | case get 5 | case post 6 | case put 7 | case patch 8 | case delete 9 | 10 | public var value: String { 11 | rawValue.uppercased() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/NetworkAssembly.swift: -------------------------------------------------------------------------------- 1 | import ASCacheKitProtocol 2 | import ASContainer 3 | 4 | public struct NetworkAssembly: Assembly { 5 | public init() {} 6 | 7 | public func assemble(container: Registerable) { 8 | container.registerSingleton(ASNetworkManagerProtocol.self) { r in 9 | let cacheManager = r.resolve(CacheManagerProtocol.self) 10 | return ASNetworkManager(cacheManager: cacheManager) 11 | } 12 | 13 | container.registerSingleton(ASFirebaseAuthProtocol.self, ASFirebaseAuth()) 14 | container.registerSingleton(ASFirebaseDatabaseProtocol.self, ASFirebaseDatabase()) 15 | container.registerSingleton(ASFirebaseStorageProtocol.self, ASFirebaseStorage()) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/Protocols/ASFirebaseAuthProtocol.swift: -------------------------------------------------------------------------------- 1 | import ASEntity 2 | import Foundation 3 | 4 | public protocol ASFirebaseAuthProtocol: Sendable { 5 | func signIn(nickname: String, avatarURL: URL?) async throws 6 | func signOut() async throws 7 | } 8 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/Protocols/ASFirebaseDatabaseProtocol.swift: -------------------------------------------------------------------------------- 1 | import ASEntity 2 | import Combine 3 | 4 | public protocol ASFirebaseDatabaseProtocol { 5 | // MARK: 특정 RoomId 변경이 생겼을 경우 이벤트를 등록하는 함수. 6 | func addRoomListener(roomNumber: String) -> AnyPublisher 7 | // MARK: 특정 RoomID 이벤트 등록을 해제하는 함수. 8 | func removeRoomListener() 9 | } 10 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/Protocols/ASFirebaseStorageProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol ASFirebaseStorageProtocol { 4 | // 플레이어 아바타 이미지 URL들을 가져오는 함수. 5 | func getAvatarUrls() async throws -> [URL] 6 | } 7 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/Protocols/ASNetworkManagerProtocol.swift: -------------------------------------------------------------------------------- 1 | import ASCacheKitProtocol 2 | import Foundation 3 | 4 | public protocol ASNetworkManagerProtocol { 5 | func sendRequest(to endpoint: any Endpoint, type: HTTPContentType, body: Data?, option: CacheOption) async throws -> Data 6 | } 7 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/Protocols/URLSessionProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol URLSessionProtocol: Sendable { 4 | func data(from url: URL) async throws -> (Data, URLResponse) 5 | func data(for request: URLRequest) async throws -> (Data, URLResponse) 6 | } 7 | 8 | extension URLSession: URLSessionProtocol {} 9 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/RequestBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class RequestBuilder { 4 | var url: URL 5 | private var header: [String: String] = [:] 6 | private var httpMethod: String = "" 7 | private var body: Data? 8 | 9 | init(using url: URL) { 10 | self.url = url 11 | } 12 | 13 | func setHeader(_ header: [String: String]) -> Self { 14 | self.header = header 15 | return self 16 | } 17 | 18 | func setHttpMethod(_ httpMethod: HTTPMethod) -> Self { 19 | self.httpMethod = httpMethod.value 20 | return self 21 | } 22 | 23 | func setBody(_ body: Data?) -> Self { 24 | self.body = body 25 | return self 26 | } 27 | 28 | func build() -> URLRequest { 29 | var request = URLRequest(url: url) 30 | request.allHTTPHeaderFields = header 31 | request.httpMethod = httpMethod 32 | request.httpBody = body 33 | return request 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKit/ResourceEndpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ResourceEndpoint: Endpoint { 4 | public var scheme: String 5 | public var host: String 6 | public var path: Path 7 | public var method: HTTPMethod 8 | public var headers: [String: String] 9 | public var body: Data? 10 | public var queryItems: [URLQueryItem]? 11 | public var url: URL? 12 | 13 | public init?(url: URL) { 14 | guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } 15 | self.url = url 16 | self.scheme = components.scheme ?? "" 17 | self.host = components.host ?? "" 18 | self.method = .get 19 | self.path = .base 20 | self.headers = [:] 21 | } 22 | 23 | public enum Path: CustomStringConvertible { 24 | case base 25 | public var description: String { 26 | switch self { 27 | case .base: 28 | "" 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKitTests/ASNetworkKitTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ASNetworkKit 2 | import Foundation 3 | import Testing 4 | 5 | struct ASNetworkKitTests { 6 | var networkManager = ASNetworkManager(urlSession: MockURLSession()) 7 | var endpoint = FirebaseEndpoint(path: .auth, method: .get) 8 | let testData = "hello, world!".data(using: .utf8)! 9 | let testResponse = HTTPURLResponse(url: URL(string: "https://www.google.com")!, statusCode: 200, httpVersion: nil, headerFields: nil)! 10 | 11 | @Test func Endpoint_생성() async throws { 12 | #expect(endpoint == FirebaseEndpoint(path: .auth, method: .get)) 13 | } 14 | 15 | @Test func Endpoint_업데이트() async throws { 16 | let updatedEndpoint = endpoint.update(\.method, with: .patch) 17 | #expect(updatedEndpoint == FirebaseEndpoint(path: .auth, method: .patch)) 18 | 19 | let twiceUpdatedEndpoint = endpoint 20 | .update(\.headers, with: ["hello": "world"]) 21 | .update(\.body, with: testData) 22 | #expect(twiceUpdatedEndpoint.headers == ["hello": "world"]) 23 | #expect(twiceUpdatedEndpoint.body == testData) 24 | } 25 | 26 | @Test func NetworkManager_올바른_응답() async throws { 27 | let mockURLSession = MockURLSession() 28 | let networkManager = ASNetworkManager(urlSession: mockURLSession) 29 | mockURLSession.testData = testData 30 | mockURLSession.testResponse = testResponse 31 | 32 | let response = try await networkManager.sendRequest(to: endpoint) 33 | #expect(response == testData) 34 | } 35 | 36 | @Test func NetworkManager_잘못된_응답() async throws { 37 | let networkManager = ASNetworkManager(urlSession: MockURLSession()) 38 | 39 | await #expect(throws: ASNetworkErrors.self, performing: { 40 | _ = try await networkManager.sendRequest(to: endpoint) 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /alsongDalsong/ASNetworkKit/ASNetworkKitTests/MockURLSession.swift: -------------------------------------------------------------------------------- 1 | import ASNetworkKit 2 | import Foundation 3 | 4 | public class MockURLSession: URLSessionProtocol { 5 | public var testData: Data? 6 | public var testResponse: HTTPURLResponse? 7 | 8 | public func data(from _: URL) async throws -> (Data, URLResponse) { 9 | let data = testData ?? Data() 10 | let response = testResponse ?? URLResponse() 11 | return (data, response) 12 | } 13 | 14 | public func data(for _: URLRequest) async throws -> (Data, URLResponse) { 15 | let data = testData ?? Data() 16 | let response = testResponse ?? URLResponse() 17 | return (data, response) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /alsongDalsong/ASRepository/ASRepository/Protocols/MainRepositoryProtocol.swift: -------------------------------------------------------------------------------- 1 | import ASEntity 2 | import Combine 3 | import Foundation 4 | 5 | public protocol MainRepositoryProtocol { 6 | var myId: String? { get } 7 | var number: CurrentValueSubject { get } 8 | var host: CurrentValueSubject { get } 9 | var players: CurrentValueSubject<[Player]?, Never> { get } 10 | var mode: CurrentValueSubject { get } 11 | var round: CurrentValueSubject { get } 12 | var status: CurrentValueSubject { get } 13 | var recordOrder: CurrentValueSubject { get } 14 | var records: CurrentValueSubject<[ASEntity.Record]?, Never> { get } 15 | var answers: CurrentValueSubject<[Answer]?, Never> { get } 16 | var dueTime: CurrentValueSubject { get } 17 | var selectedRecords: CurrentValueSubject<[UInt8]?, Never> { get } 18 | var submits: CurrentValueSubject<[Answer]?, Never> { get } 19 | 20 | func connectRoom(roomNumber: String) 21 | func disconnectRoom() 22 | 23 | func postRecording(_ record: Data) async throws -> Bool 24 | func postResetGame() async throws -> Bool 25 | } 26 | -------------------------------------------------------------------------------- /alsongDalsong/ASRepository/ASRepository/Repositories/AnswersRepository.swift: -------------------------------------------------------------------------------- 1 | import ASDecoder 2 | import ASEncoder 3 | import ASEntity 4 | import ASNetworkKit 5 | import Combine 6 | import Foundation 7 | import ASRepositoryProtocol 8 | 9 | public final class AnswersRepository: AnswersRepositoryProtocol { 10 | private var mainRepository: MainRepositoryProtocol 11 | private var networkManager: ASNetworkManagerProtocol 12 | public init(mainRepository: MainRepositoryProtocol, networkManager: ASNetworkManagerProtocol) { 13 | self.mainRepository = mainRepository 14 | self.networkManager = networkManager 15 | } 16 | 17 | public func getAnswers() -> AnyPublisher<[Answer], Never> { 18 | mainRepository.answers 19 | .receive(on: DispatchQueue.main) 20 | .compactMap { $0 } 21 | .eraseToAnyPublisher() 22 | } 23 | 24 | public func getAnswersCount() -> AnyPublisher { 25 | mainRepository.answers 26 | .receive(on: DispatchQueue.main) 27 | .compactMap { $0 } 28 | .map { $0.count } 29 | .eraseToAnyPublisher() 30 | } 31 | 32 | public func getMyAnswer() -> AnyPublisher { 33 | guard let myId = mainRepository.myId else { 34 | return Just(nil).eraseToAnyPublisher() 35 | } 36 | 37 | return mainRepository.answers 38 | .receive(on: DispatchQueue.main) 39 | .compactMap(\.self) 40 | .flatMap { answers in 41 | Just(answers.first { $0.player?.id == myId }) 42 | .eraseToAnyPublisher() 43 | } 44 | .eraseToAnyPublisher() 45 | } 46 | 47 | public func submitMusic(answer: ASEntity.Music) async throws -> Bool { 48 | let queryItems = [URLQueryItem(name: "userId", value: ASFirebaseAuth.myID), 49 | URLQueryItem(name: "roomNumber", value: mainRepository.number.value)] 50 | let endPoint = FirebaseEndpoint(path: .submitMusic, method: .post) 51 | .update(\.queryItems, with: queryItems) 52 | 53 | let body = try ASEncoder.encode(answer) 54 | let response = try await networkManager.sendRequest(to: endPoint, type: .json, body: body, option: .none) 55 | let responseDict = try ASDecoder.decode([String: String].self, from: response) 56 | return !responseDict.isEmpty 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /alsongDalsong/ASRepository/ASRepository/Repositories/AvatarRepository.swift: -------------------------------------------------------------------------------- 1 | import ASNetworkKit 2 | import Combine 3 | import Foundation 4 | import ASRepositoryProtocol 5 | 6 | public final class AvatarRepository: AvatarRepositoryProtocol { 7 | // TODO: - Container로 주입 8 | private let storageManager: ASFirebaseStorageProtocol 9 | 10 | public init ( 11 | storageManager: ASFirebaseStorageProtocol 12 | ) { 13 | self.storageManager = storageManager 14 | } 15 | 16 | public func getAvatarUrls() async throws -> [URL] { 17 | do { 18 | let urls = try await self.storageManager.getAvatarUrls() 19 | return urls 20 | } catch { 21 | throw error 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /alsongDalsong/ASRepository/ASRepository/Repositories/DataDownloadRepository.swift: -------------------------------------------------------------------------------- 1 | import ASNetworkKit 2 | import ASRepositoryProtocol 3 | 4 | public final class DataDownloadRepository: DataDownloadRepositoryProtocol { 5 | private var networkManager: ASNetworkManagerProtocol 6 | 7 | public init(networkManager: ASNetworkManagerProtocol) { 8 | self.networkManager = networkManager 9 | } 10 | 11 | public func downloadData(url: URL) async -> Data? { 12 | guard let endpoint = ResourceEndpoint(url: url) else { return nil } 13 | do { 14 | let data = try await networkManager.sendRequest(to: endpoint, type: .none, body: nil, option: .both) 15 | return data 16 | } catch { 17 | return nil 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /alsongDalsong/ASRepository/ASRepository/Repositories/GameStateRepository.swift: -------------------------------------------------------------------------------- 1 | import ASEntity 2 | import Combine 3 | import Foundation 4 | import ASRepositoryProtocol 5 | 6 | public final class GameStateRepository: GameStateRepositoryProtocol { 7 | private var mainRepository: MainRepositoryProtocol 8 | 9 | public init(mainRepository: MainRepositoryProtocol) { 10 | self.mainRepository = mainRepository 11 | } 12 | 13 | public func getGameState() -> AnyPublisher { 14 | Publishers.CombineLatest4(mainRepository.mode, mainRepository.recordOrder, mainRepository.status, mainRepository.round) 15 | .receive(on: DispatchQueue.main) 16 | .map { mode, recordOrder, status, round in 17 | guard let mode, let round, let players = self.mainRepository.players.value else { return nil } 18 | return ASEntity.GameState(mode: mode, recordOrder: recordOrder, status: status, round: round, players: players) 19 | } 20 | .eraseToAnyPublisher() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /alsongDalsong/ASRepository/ASRepository/Repositories/GameStatusRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import ASEntity 4 | import ASRepositoryProtocol 5 | 6 | public final class GameStatusRepository: GameStatusRepositoryProtocol { 7 | private var mainRepository: MainRepositoryProtocol 8 | 9 | public init(mainRepository: MainRepositoryProtocol) { 10 | self.mainRepository = mainRepository 11 | } 12 | 13 | public func getStatus() -> AnyPublisher { 14 | mainRepository.status 15 | .receive(on: DispatchQueue.main) 16 | .eraseToAnyPublisher() 17 | } 18 | 19 | public func getRound() -> AnyPublisher { 20 | mainRepository.round 21 | .receive(on: DispatchQueue.main) 22 | .compactMap { $0 } 23 | .eraseToAnyPublisher() 24 | } 25 | 26 | public func getRecordOrder() -> AnyPublisher { 27 | mainRepository.recordOrder 28 | .receive(on: DispatchQueue.main) 29 | .compactMap { $0 } 30 | .eraseToAnyPublisher() 31 | } 32 | 33 | public func getDueTime() -> AnyPublisher { 34 | mainRepository.dueTime 35 | .receive(on: DispatchQueue.main) 36 | .compactMap { $0 } 37 | .eraseToAnyPublisher() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /alsongDalsong/ASRepository/ASRepository/Repositories/PlayersRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ASNetworkKit 3 | import Combine 4 | import ASEntity 5 | import ASRepositoryProtocol 6 | 7 | public final class PlayersRepository: PlayersRepositoryProtocol { 8 | private var mainRepository: MainRepositoryProtocol 9 | private var firebaseAuthManager: ASFirebaseAuthProtocol 10 | 11 | public init(mainRepository: MainRepositoryProtocol, 12 | firebaseAuthManager: ASFirebaseAuthProtocol) { 13 | self.mainRepository = mainRepository 14 | self.firebaseAuthManager = firebaseAuthManager 15 | } 16 | 17 | public func getPlayers() -> AnyPublisher<[Player], Never> { 18 | mainRepository.players 19 | .receive(on: DispatchQueue.main) 20 | .compactMap { $0 } 21 | .eraseToAnyPublisher() 22 | } 23 | 24 | public func getPlayersCount() -> AnyPublisher { 25 | mainRepository.players 26 | .receive(on: DispatchQueue.main) 27 | .compactMap { $0 } 28 | .map { $0.count } 29 | .eraseToAnyPublisher() 30 | } 31 | 32 | public func getHost() -> AnyPublisher { 33 | mainRepository.host 34 | .receive(on: DispatchQueue.main) 35 | .compactMap { $0 } 36 | .eraseToAnyPublisher() 37 | } 38 | 39 | public func isHost() -> AnyPublisher { 40 | self.getHost() 41 | .receive(on: DispatchQueue.main) 42 | .map { $0.id == ASFirebaseAuth.myID } 43 | .eraseToAnyPublisher() 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /alsongDalsong/ASRepository/ASRepository/Repositories/RoomInfoRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import ASEntity 4 | import ASRepositoryProtocol 5 | 6 | public final class RoomInfoRepository: RoomInfoRepositoryProtocol { 7 | private var mainRepository: MainRepositoryProtocol 8 | 9 | public init(mainRepository: MainRepositoryProtocol) { 10 | self.mainRepository = mainRepository 11 | } 12 | 13 | public func getRoomNumber() -> AnyPublisher { 14 | mainRepository.number 15 | .receive(on: DispatchQueue.main) 16 | .compactMap { $0 } 17 | .eraseToAnyPublisher() 18 | } 19 | 20 | public func getMode() -> AnyPublisher { 21 | mainRepository.mode 22 | .receive(on: DispatchQueue.main) 23 | .compactMap { $0 } 24 | .eraseToAnyPublisher() 25 | } 26 | 27 | public func getRecordOrder() -> AnyPublisher { 28 | mainRepository.recordOrder 29 | .receive(on: DispatchQueue.main) 30 | .compactMap { $0 } 31 | .eraseToAnyPublisher() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /alsongDalsong/ASRepository/ASRepository/Repositories/SelectedRecordsRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import ASEntity 4 | import ASRepositoryProtocol 5 | 6 | public final class SelectedRecordsRepository: SelectedRecordsRepositoryProtocol { 7 | private var mainRepository: MainRepositoryProtocol 8 | 9 | public init(mainRepository: MainRepositoryProtocol) { 10 | self.mainRepository = mainRepository 11 | } 12 | 13 | public func getSelectedRecords() -> AnyPublisher<[UInt8], Never> { 14 | mainRepository.selectedRecords 15 | .receive(on: DispatchQueue.main) 16 | .compactMap { $0 } 17 | .eraseToAnyPublisher() 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /alsongDalsong/ASRepository/ASRepository/Repositories/SubmitsRepository.swift: -------------------------------------------------------------------------------- 1 | import ASDecoder 2 | import ASEncoder 3 | import ASEntity 4 | import ASNetworkKit 5 | import Combine 6 | import Foundation 7 | import ASRepositoryProtocol 8 | 9 | public final class SubmitsRepository: SubmitsRepositoryProtocol { 10 | private var mainRepository: MainRepositoryProtocol 11 | private var networkManager: ASNetworkManagerProtocol 12 | 13 | public init(mainRepository: MainRepositoryProtocol, networkManager: ASNetworkManagerProtocol) { 14 | self.mainRepository = mainRepository 15 | self.networkManager = networkManager 16 | } 17 | 18 | public func getSubmits() -> AnyPublisher<[Answer], Never> { 19 | mainRepository.answers 20 | .receive(on: DispatchQueue.main) 21 | .compactMap { $0 } 22 | .eraseToAnyPublisher() 23 | } 24 | 25 | public func getSubmitsCount() -> AnyPublisher { 26 | mainRepository.submits 27 | .receive(on: DispatchQueue.main) 28 | .compactMap { $0 } 29 | .map { $0.count } 30 | .eraseToAnyPublisher() 31 | } 32 | 33 | public func submitAnswer(answer: Music) async throws -> Bool { 34 | let queryItems = [URLQueryItem(name: "userId", value: ASFirebaseAuth.myID), 35 | URLQueryItem(name: "roomNumber", value: mainRepository.number.value)] 36 | let endPoint = FirebaseEndpoint(path: .submitAnswer, method: .post) 37 | .update(\.queryItems, with: queryItems) 38 | .update(\.headers, with: ["Content-Type": "application/json"]) 39 | 40 | let body = try ASEncoder.encode(answer) 41 | let response = try await networkManager.sendRequest(to: endPoint, type: .json, body: body, option: .none) 42 | let responseDict = try ASDecoder.decode([String: String].self, from: response) 43 | return !responseDict.isEmpty 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /alsongDalsong/ASRepository/ASRepositoryProtocol/ASRepositoryProtocol.h: -------------------------------------------------------------------------------- 1 | // 2 | // ASRepositoryProtocol.h 3 | // ASRepositoryProtocol 4 | // 5 | // Created by 박상원 on 11/30/24. 6 | // 7 | 8 | #import 9 | 10 | //! Project version number for ASRepositoryProtocol. 11 | FOUNDATION_EXPORT double ASRepositoryProtocolVersionNumber; 12 | 13 | //! Project version string for ASRepositoryProtocol. 14 | FOUNDATION_EXPORT const unsigned char ASRepositoryProtocolVersionString[]; 15 | 16 | // In this header, you should import all the public headers of your framework using statements like #import 17 | 18 | 19 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | only_rules: 2 | - colon 3 | - fatal_error_message 4 | - implicitly_unwrapped_optional 5 | - legacy_cggeometry_functions 6 | - legacy_constant 7 | - legacy_constructor 8 | - legacy_nsgeometry_functions 9 | - operator_usage_whitespace 10 | - return_arrow_whitespace 11 | - trailing_newline 12 | - unused_optional_binding 13 | - vertical_whitespace 14 | - void_return 15 | - custom_rules 16 | - line_length 17 | - identifier_name 18 | 19 | excluded: 20 | - Carthage 21 | - Pods 22 | - .build 23 | 24 | colon: 25 | apply_to_dictionaries: false 26 | 27 | indentation: 4 28 | 29 | line_length: 140 30 | 31 | identifier_name: 32 | min_length: 33 | warning: 0 34 | error: 0 35 | max_length: 36 | warning: 130 37 | error: 150 38 | allowed_symbols: 39 | - $ 40 | - _ 41 | 42 | custom_rules: 43 | no_objcMembers: 44 | name: "@objcMembers" 45 | regex: "@objcMembers" 46 | message: "Explicitly use @objc on each member you want to expose to Objective-C" 47 | severity: error 48 | 49 | no_file_literal: 50 | name: "#file is disallowed" 51 | regex: "(\\b#file\\b)" 52 | match_kinds: 53 | - identifier 54 | message: "Instead of #file, use #fileID" 55 | no_filepath_literal: 56 | name: "#filePath is disallowed" 57 | regex: "(\\b#filePath\\b)" 58 | match_kinds: 59 | - identifier 60 | message: "Instead of #filePath, use #fileID." 61 | 62 | excluded: 63 | - "*/*Tests.swift" 64 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Localizable.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "ko", 3 | "strings" : { 4 | "HarmonyModeDescription" : { 5 | "extractionState" : "manual", 6 | "localizations" : { 7 | "en" : { 8 | "stringUnit" : { 9 | "state" : "translated", 10 | "value" : "this is hamony" 11 | } 12 | }, 13 | "ko" : { 14 | "stringUnit" : { 15 | "state" : "translated", 16 | "value" : "하모니 설명입니다" 17 | } 18 | } 19 | } 20 | }, 21 | "HummingModeDescription" : { 22 | "extractionState" : "manual", 23 | "localizations" : { 24 | "en" : { 25 | "stringUnit" : { 26 | "state" : "translated", 27 | "value" : "this is humming" 28 | } 29 | }, 30 | "ko" : { 31 | "stringUnit" : { 32 | "state" : "translated", 33 | "value" : "허밍모드 설명입니다" 34 | } 35 | } 36 | } 37 | }, 38 | "InstantModeDescription" : { 39 | "extractionState" : "manual", 40 | "localizations" : { 41 | "en" : { 42 | "stringUnit" : { 43 | "state" : "translated", 44 | "value" : "this is instant" 45 | } 46 | }, 47 | "ko" : { 48 | "stringUnit" : { 49 | "state" : "translated", 50 | "value" : "찰나의순간 설명입니다." 51 | } 52 | } 53 | } 54 | }, 55 | "SyncModeDescription" : { 56 | "extractionState" : "manual", 57 | "localizations" : { 58 | "en" : { 59 | "stringUnit" : { 60 | "state" : "translated", 61 | "value" : "this is sync" 62 | } 63 | }, 64 | "ko" : { 65 | "stringUnit" : { 66 | "state" : "translated", 67 | "value" : "이구동성 설명입니다" 68 | } 69 | } 70 | } 71 | }, 72 | "TTSModeDescription" : { 73 | "extractionState" : "manual", 74 | "localizations" : { 75 | "en" : { 76 | "stringUnit" : { 77 | "state" : "translated", 78 | "value" : "this is tts" 79 | } 80 | }, 81 | "ko" : { 82 | "stringUnit" : { 83 | "state" : "translated", 84 | "value" : "TTS모드 설명입니다" 85 | } 86 | } 87 | } 88 | }, 89 | "비어 있음" : { 90 | 91 | }, 92 | "완료" : { 93 | 94 | }, 95 | "음악을 선택하세요!" : { 96 | 97 | } 98 | }, 99 | "version" : "1.0" 100 | } -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-38@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-38@2x.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-38@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-38@3x.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-64@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-64@2x.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-64@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-64@3x.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-68@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-68@2x.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-83_5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/icon-83_5@2x.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/ios-marketing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/AppIcon.appiconset/ios-marketing.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/harmony.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "harmony.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/harmony.imageset/harmony.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/harmony.imageset/harmony.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/humming.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "humming.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/humming.imageset/humming.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/humming.imageset/humming.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/instant.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "instant.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/instant.imageset/instant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/instant.imageset/instant.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/sync.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "sync.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/sync.imageset/sync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/sync.imageset/sync.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/tts.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "tts.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/tts.imageset/tts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/ModeImage/tts.imageset/tts.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/asBlack.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.000", 9 | "green" : "0.000", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/asGreen.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.063", 9 | "green" : "0.835", 10 | "red" : "0.165" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.063", 27 | "green" : "0.835", 28 | "red" : "0.165" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/asLightGray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xF6", 9 | "green" : "0xF4", 10 | "red" : "0xF2" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.255", 27 | "green" : "0.220", 28 | "red" : "0.188" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/asLightRed.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.478", 9 | "green" : "0.478", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x52", 27 | "green" : "0x52", 28 | "red" : "0xF0" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/asLightSky.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.969", 9 | "green" : "0.922", 10 | "red" : "0.624" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xD9", 27 | "green" : "0xC5", 28 | "red" : "0x46" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/asMint.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.690", 9 | "green" : "0.902", 10 | "red" : "0.055" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.455", 27 | "green" : "0.261", 28 | "red" : "0.055" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/asOrange.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x22", 9 | "green" : "0x98", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x1E", 27 | "green" : "0x92", 28 | "red" : "0xF7" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/asShadow.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.255", 9 | "green" : "0.220", 10 | "red" : "0.188" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "0.000", 26 | "blue" : "0.945", 27 | "green" : "0.941", 28 | "red" : "0.925" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/asSystem.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.231", 27 | "green" : "0.161", 28 | "red" : "0.118" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/asYellow.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.133", 9 | "green" : "0.812", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.133", 27 | "green" : "0.605", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "logo.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/logo.imageset/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/logo.imageset/logo.png -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Fonts/DoHyeon-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Fonts/DoHyeon-Regular.ttf -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSAppleMusicUsageDescription 6 | 알쏭달쏭은 Apple Music을 이용하여 음악을 찾는 것을 돕습니다 7 | NSMicrophoneUsageDescription 8 | 허밍해보세요! 9 | CFBundleURLTypes 10 | 11 | 12 | CFBundleTypeRole 13 | Editor 14 | CFBundleURLSchemes 15 | 16 | alsongDalsong 17 | 18 | 19 | 20 | SERVER_URL 21 | $(SERVER_URL) 22 | UIAppFonts 23 | 24 | DoHyeon-Regular.ttf 25 | 26 | UIApplicationSceneManifest 27 | 28 | UIApplicationSupportsMultipleScenes 29 | 30 | UISceneConfigurations 31 | 32 | UIWindowSceneSessionRoleApplication 33 | 34 | 35 | UISceneConfigurationName 36 | Default Configuration 37 | UISceneDelegateClassName 38 | $(PRODUCT_MODULE_NAME).SceneDelegate 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/SampleRecord.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS07-alsongDalsong/072bb9968ede3caaba9e93d46ced9aa2c072d665/alsongDalsong/alsongDalsong/alsongDalsong/Resources/SampleRecord.m4a -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Resources/Secret.xcconfig: -------------------------------------------------------------------------------- 1 | SERVER_URL = 비밀 2 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @main 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 6 | return true 7 | } 8 | 9 | func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration { 10 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SwiftUIComponents/ASButtonStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ASButtonStyle: ButtonStyle { 4 | var backgroundColor: Color 5 | func makeBody(configuration: Configuration) -> some View { 6 | HStack { 7 | configuration.label 8 | .font(.doHyeon(size: 32)) 9 | } 10 | .tint(.black) 11 | .frame(maxWidth: 345, maxHeight: 64) 12 | .background(backgroundColor) 13 | .cornerRadius(12) 14 | .shadow(color: .asShadow, radius: 0, x: 5, y: 5) 15 | .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.black, lineWidth: 3)) 16 | } 17 | } 18 | 19 | #Preview { 20 | Button { 21 | 22 | } label: { 23 | Image(systemName: "link") 24 | Text("hi") 25 | } 26 | .buttonStyle(ASButtonStyle(backgroundColor: Color(.asMint))) 27 | } 28 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SwiftUIComponents/ModeView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import ASEntity 3 | 4 | struct ModeView: View { 5 | let modeInfo: Mode 6 | let width: CGFloat 7 | var body: some View { 8 | ZStack { 9 | Rectangle() 10 | .foregroundColor(Color.asSystem) 11 | .cornerRadius(12) 12 | .shadow(color: .asShadow, radius: 0, x: 5, y: 5) 13 | .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.black, lineWidth: 3)) 14 | VStack { 15 | Text(modeInfo.title) 16 | .font(.doHyeon(size: 32)) 17 | .padding(.top, 16) 18 | Image(modeInfo.imageName) 19 | .resizable() 20 | .aspectRatio(contentMode: .fill) 21 | .frame(width: 300, height: 180) 22 | .clipShape(RoundedRectangle(cornerRadius: 8)) 23 | .padding() 24 | Text(modeInfo.description) 25 | .font(.doHyeon(size: 20)) 26 | .padding(.horizontal) 27 | .minimumScaleFactor(0.01) 28 | Spacer() 29 | } 30 | 31 | } 32 | .frame(width: width) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SwiftUIComponents/ProfileView.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import SwiftUI 4 | 5 | struct AsyncImageView: View { 6 | let imagePublisher: (URL?) async -> Data? 7 | let url: URL? 8 | @State private var imageData: Data? 9 | 10 | var body: some View { 11 | Group { 12 | if let imageData, let uiImage = UIImage(data: imageData) { 13 | Image(uiImage: uiImage) 14 | .resizable() 15 | .aspectRatio(contentMode: .fill) 16 | } else { 17 | Image(systemName: "person.circle.fill") 18 | .resizable() 19 | .aspectRatio(contentMode: .fill) 20 | .clipShape(Circle()) 21 | .overlay(Circle().stroke(Color.white, lineWidth: 5)) 22 | } 23 | } 24 | .onAppear { 25 | Task { 26 | imageData = await imagePublisher(url) 27 | } 28 | } 29 | } 30 | } 31 | 32 | struct ProfileView: View { 33 | let imagePublisher: (URL?) async -> Data? 34 | let name: String? 35 | let isHost: Bool 36 | let imageUrl: URL? 37 | 38 | var body: some View { 39 | VStack { 40 | AsyncImageView(imagePublisher: imagePublisher, url: imageUrl) 41 | .background(Color.asMint) 42 | .frame(width: 75, height: 75) 43 | .clipShape(Circle()) 44 | .overlay(Circle().stroke(Color.white, lineWidth: 5)) 45 | .overlay(alignment: .top) { 46 | isHost ? Image(systemName: "crown.fill") 47 | .foregroundStyle(.asYellow) 48 | .font(.system(size: 20)) 49 | .offset(y: -20) 50 | : nil 51 | } 52 | .padding(.bottom, 4) 53 | if let name { 54 | Text(name) 55 | .font(.doHyeon(size: 16)) 56 | .multilineTextAlignment(.center) 57 | .lineLimit(2) 58 | } else { 59 | Text("비어 있음") 60 | .font(.doHyeon(size: 16)) 61 | .multilineTextAlignment(.center) 62 | .lineLimit(2) 63 | } 64 | } 65 | .frame(width: 75) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SwiftUIComponents/SnapperView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import ASEntity 3 | 4 | struct SnapperView: View { 5 | private let size: CGSize 6 | private let modeInfos = Mode.allCases 7 | private let padding: CGFloat 8 | private let cardWidth: CGFloat 9 | private let spacing: CGFloat = 15.0 10 | private let maxSwipeDistance: CGFloat 11 | 12 | @Binding private var currentMode: Mode 13 | @State private var isDragging: Bool = false 14 | @State private var totalDrag: CGFloat = 0.0 15 | 16 | init(size: CGSize, currentMode: Binding) { 17 | self.size = size 18 | self.cardWidth = size.width * 0.85 19 | self.padding = (size.width - cardWidth) / 2.0 20 | self.maxSwipeDistance = cardWidth + spacing 21 | self._currentMode = currentMode 22 | } 23 | 24 | var body: some View { 25 | let offset: CGFloat = maxSwipeDistance - (maxSwipeDistance * CGFloat(currentMode.Index)) 26 | 27 | HStack(spacing: spacing) { 28 | ForEach(modeInfos, id: \.id) { card in 29 | ModeView(modeInfo: card, width: cardWidth) 30 | .offset(x: isDragging ? totalDrag : 0) 31 | .animation(.snappy(duration: 0.4, extraBounce: 0.2), value: isDragging) 32 | } 33 | } 34 | .padding(.horizontal, padding) 35 | .offset(x: offset, y: 0) 36 | .gesture( 37 | DragGesture() 38 | .onChanged { value in 39 | isDragging = true 40 | totalDrag = value.translation.width 41 | } 42 | .onEnded { value in 43 | isDragging = false 44 | totalDrag = 0.0 45 | 46 | if (value.translation.width < -(cardWidth / 2.0) && self.currentMode.Index < modeInfos.count) { 47 | self.currentMode = Mode.fromIndex(self.currentMode.Index + 1) ?? .harmony 48 | } 49 | if (value.translation.width > (cardWidth / 2.0) && self.currentMode.Index > 1) { 50 | self.currentMode = Mode.fromIndex(self.currentMode.Index - 1) ?? .harmony 51 | } 52 | } 53 | ) 54 | } 55 | } 56 | 57 | #Preview { 58 | SnapperView( 59 | size: CGSize( 60 | width: 300, 61 | height: 400 62 | ), 63 | currentMode: .constant(.harmony) 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/ASAvatarCircleView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import ASCacheKit 4 | import ASNetworkKit 5 | 6 | final class ASAvatarCircleView: UIView { 7 | private var imageView = UIImageView() 8 | 9 | init(backgroundColor: UIColor = .asMint) { 10 | super.init(frame: .zero) 11 | setup(backgroundColor: backgroundColor) 12 | } 13 | 14 | required init?(coder: NSCoder) { 15 | fatalError("init(coder:) has not been implemented") 16 | } 17 | 18 | private func setup(backgroundColor: UIColor) { 19 | layer.cornerRadius = 100 20 | layer.masksToBounds = true 21 | layer.backgroundColor = backgroundColor.cgColor 22 | 23 | layer.borderWidth = 10 24 | layer.borderColor = UIColor.white.cgColor 25 | 26 | clipsToBounds = true 27 | imageView.contentMode = .scaleAspectFit 28 | addSubview(imageView) 29 | 30 | imageView.translatesAutoresizingMaskIntoConstraints = false 31 | NSLayoutConstraint.activate([ 32 | imageView.topAnchor.constraint(equalTo: topAnchor), 33 | imageView.bottomAnchor.constraint(equalTo: bottomAnchor), 34 | imageView.leadingAnchor.constraint(equalTo: leadingAnchor), 35 | imageView.trailingAnchor.constraint(equalTo: trailingAnchor) 36 | ]) 37 | } 38 | 39 | func setImage(imageData: Data?) { 40 | guard let imageData else { return } 41 | imageView.image = UIImage(data: imageData) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/ASPanel.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class ASPanel: UIView { 4 | init() { 5 | super.init(frame: .zero) 6 | setupUI() 7 | } 8 | 9 | required init?(coder: NSCoder) { 10 | super.init(coder: coder) 11 | } 12 | 13 | func updateBackgroundColor(_ color: UIColor) { 14 | backgroundColor = color 15 | } 16 | 17 | private func setupUI() { 18 | layer.cornerRadius = 12 19 | layer.borderColor = UIColor.black.cgColor 20 | layer.borderWidth = 3 21 | backgroundColor = .asSystem 22 | setShadow() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/ASRefreshButton.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class ASRefreshButton: UIButton { 4 | 5 | init(size: CGFloat) { 6 | super.init(frame: .zero) 7 | setConfiguration(size: size) 8 | } 9 | 10 | @available(*, unavailable) 11 | required init?(coder: NSCoder) { 12 | fatalError("init(coder:) has not been implemented") 13 | } 14 | 15 | private func setConfiguration(size: CGFloat) { 16 | var config = UIButton.Configuration.gray() 17 | 18 | config.baseBackgroundColor = .asSystem 19 | config.baseForegroundColor = .asBlack 20 | 21 | config.imagePlacement = .all 22 | config.image = UIImage(systemName: "arrow.clockwise") 23 | config.cornerStyle = .capsule 24 | 25 | let imageConfig = UIImage.SymbolConfiguration(pointSize: size, weight: .bold) 26 | config.preferredSymbolConfigurationForImage = imageConfig 27 | config.cornerStyle = .capsule 28 | 29 | configuration = config 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/ASTextField.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class ASTextField: UITextField { 4 | init() { 5 | super.init(frame: .zero) 6 | } 7 | 8 | func setConfiguration( 9 | placeholder: String?, 10 | backgroundColor: UIColor = .asSystem, 11 | textSize: CGFloat = 32 12 | ) { 13 | layer.cornerRadius = 12 14 | 15 | attributedPlaceholder = NSAttributedString(string: placeholder ?? "", attributes: [.foregroundColor: UIColor.lightGray]) 16 | self.backgroundColor = backgroundColor 17 | font = UIFont.font(.dohyeon, ofSize: textSize) 18 | textColor = .asBlack 19 | attributedText?.addObserver(self, forKeyPath: "string", options: .new, context: nil) 20 | leftView = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 0)) 21 | leftViewMode = .always 22 | } 23 | 24 | required init?(coder: NSCoder) { 25 | super.init(coder: coder) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/Alert/DefaultAlertController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class DefaultAlertController: ASAlertController { 4 | override func viewDidLoad() { 5 | super.viewDidLoad() 6 | setupStyle() 7 | } 8 | 9 | func setupStyle() { 10 | setTitle() 11 | setButtonStackView() 12 | setSecondaryButton() 13 | setPrimaryButton() 14 | } 15 | 16 | override func setPrimaryButton() { 17 | super.setPrimaryButton() 18 | primaryButton.addAction(UIAction { [weak self] _ in 19 | self?.primaryButtonAction?("") 20 | }, for: .touchUpInside) 21 | } 22 | 23 | override func setSecondaryButton() { 24 | super.setSecondaryButton() 25 | secondaryButton.addAction(UIAction { [weak self] _ in 26 | self?.secondaryButtonAction?() 27 | }, for: .touchUpInside) 28 | } 29 | 30 | convenience init( 31 | titleText: ASAlertText.Title, 32 | primaryButtonText: ASAlertText.ButtonText = .done, 33 | secondaryButtonText: ASAlertText.ButtonText = .cancel, 34 | reversedColor: Bool = false, 35 | primaryButtonAction: ((String) -> Void)? = nil, 36 | secondaryButtonAction: (() -> Void)? = nil 37 | ) { 38 | self.init() 39 | self.titleText = titleText 40 | self.primaryButtonText = primaryButtonText 41 | self.secondaryButtonText = secondaryButtonText 42 | self.reversedColor = reversedColor 43 | self.primaryButtonAction = primaryButtonAction 44 | self.secondaryButtonAction = secondaryButtonAction 45 | 46 | modalTransitionStyle = .crossDissolve 47 | modalPresentationStyle = .overFullScreen 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/Alert/LoadingAlertController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class LoadingAlertController: ASAlertController { 4 | var load: (() async throws -> Void)? 5 | var errorCompletion: ((Error) -> Void)? 6 | var progressText: ASAlertText.ProgressText? 7 | 8 | override func viewDidLoad() { 9 | super.viewDidLoad() 10 | setupStyle() 11 | } 12 | 13 | override func alertViewWidthConstraint() -> NSLayoutConstraint { 14 | return alertView.widthAnchor.constraint(equalToConstant: 232) 15 | } 16 | 17 | func setupStyle() { 18 | setProgressView() 19 | setProgressText() 20 | } 21 | 22 | func setProgressView() { 23 | stackView.addArrangedSubview(progressView) 24 | progressView.translatesAutoresizingMaskIntoConstraints = false 25 | progressView.startAnimating() 26 | progressView.style = .large 27 | progressView.topAnchor.constraint(equalTo: stackView.topAnchor, constant: 12).isActive = true 28 | progressView.hidesWhenStopped = true 29 | Task { 30 | guard let load else { return } 31 | do { 32 | try await load() 33 | dismiss(animated: true) 34 | } catch { 35 | dismiss(animated: true) { [weak self] in 36 | self?.errorCompletion?(error) 37 | } 38 | } 39 | } 40 | } 41 | 42 | private func setProgressText() { 43 | let progressLabel = UILabel() 44 | progressLabel.text = progressText?.description 45 | progressLabel.font = .font(forTextStyle: .title2) 46 | progressLabel.textColor = .label 47 | stackView.addArrangedSubview(progressLabel) 48 | progressLabel.translatesAutoresizingMaskIntoConstraints = false 49 | progressLabel.adjustsFontSizeToFitWidth = true 50 | progressLabel.bottomAnchor.constraint(equalTo: stackView.bottomAnchor, constant: 12).isActive = true 51 | } 52 | 53 | convenience init( 54 | progressText: ASAlertText.ProgressText, 55 | loadAction: (() async throws -> Void)? = nil, 56 | errorCompletion: ((Error) -> Void)? = nil 57 | ) { 58 | self.init() 59 | self.progressText = progressText 60 | self.load = loadAction 61 | self.errorCompletion = errorCompletion 62 | 63 | modalTransitionStyle = .crossDissolve 64 | modalPresentationStyle = .overFullScreen 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/Alert/SingleButtonAlertController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class SingleButtonAlertController: ASAlertController { 4 | override func viewDidLoad() { 5 | super.viewDidLoad() 6 | setupStyle() 7 | } 8 | 9 | func setupStyle() { 10 | setTitle() 11 | setButtonStackView() 12 | setPrimaryButton() 13 | } 14 | 15 | override func setPrimaryButton() { 16 | super.setPrimaryButton() 17 | primaryButton.addAction(UIAction { [weak self] _ in 18 | self?.primaryButtonAction?("") 19 | }, for: .touchUpInside) 20 | } 21 | 22 | convenience init( 23 | titleText: ASAlertText.Title, 24 | primaryButtonText: ASAlertText.ButtonText = .confirm, 25 | primaryButtonAction: ((String) -> Void)? = nil 26 | ) { 27 | self.init() 28 | self.titleText = titleText 29 | self.primaryButtonText = primaryButtonText 30 | self.primaryButtonAction = primaryButtonAction 31 | 32 | modalTransitionStyle = .crossDissolve 33 | modalPresentationStyle = .overFullScreen 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/GuideLabel.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class GuideLabel: UILabel { 4 | init(style: UIFont.TextStyle = .largeTitle) { 5 | super.init(frame: .zero) 6 | font = .font(forTextStyle: style) 7 | textColor = .label 8 | textAlignment = .center 9 | numberOfLines = 0 10 | } 11 | 12 | @available(*, unavailable) 13 | required init?(coder _: NSCoder) { 14 | fatalError("init(coder:) has not been implemented") 15 | } 16 | 17 | func setText(_ text: String) { 18 | self.text = text 19 | sizeToFit() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/SubmissionStatusView.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import UIKit 3 | 4 | final class SubmissionStatusView: UIStackView { 5 | let label = UILabel() 6 | 7 | private var cancellables = Set() 8 | 9 | init() { 10 | super.init(frame: .zero) 11 | setupUI() 12 | } 13 | 14 | @available(*, unavailable) 15 | required init(coder _: NSCoder) { 16 | fatalError("init(coder:) has not been implemented") 17 | } 18 | 19 | override var intrinsicContentSize: CGSize { 20 | return CGSize(width: 64, height: 30) 21 | } 22 | 23 | private func setupUI() { 24 | backgroundColor = .asSystem 25 | layer.cornerRadius = intrinsicContentSize.height / 2 26 | clipsToBounds = true 27 | layer.borderColor = UIColor.label.cgColor 28 | layer.borderWidth = 2.5 29 | 30 | setupStack() 31 | setupImage() 32 | setupLabel() 33 | } 34 | 35 | func bind( 36 | to dataSource: Published<(submits: String, total: String)>.Publisher 37 | ) { 38 | dataSource 39 | .receive(on: DispatchQueue.main) 40 | .sink { [weak self] status in 41 | self?.label.text = "\(status.submits)/\(status.total)" 42 | } 43 | .store(in: &cancellables) 44 | } 45 | 46 | private func setupStack() { 47 | axis = .horizontal 48 | alignment = .center 49 | spacing = 2 50 | isLayoutMarginsRelativeArrangement = true 51 | layoutMargins = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 8) 52 | } 53 | 54 | private func setupImage() { 55 | let configuration = UIImage.SymbolConfiguration(pointSize: 20, weight: .bold) 56 | let image = UIImage(systemName: "checkmark", withConfiguration: configuration) 57 | 58 | let imageView = UIImageView(image: image) 59 | imageView.tintColor = .label 60 | imageView.contentMode = .scaleAspectFit 61 | imageView.translatesAutoresizingMaskIntoConstraints = false 62 | imageView.widthAnchor.constraint(equalToConstant: 16).isActive = true 63 | addArrangedSubview(imageView) 64 | } 65 | 66 | private func setupLabel() { 67 | label.font = .font(forTextStyle: .body) 68 | addArrangedSubview(label) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/WaveFormWrapper.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct WaveFormWrapper: UIViewRepresentable { 4 | let columns: [CGFloat] 5 | let sampleCount: Int 6 | let circleColor: UIColor 7 | let highlightColor: UIColor 8 | 9 | func makeUIView(context: Context) -> WaveForm { 10 | let view = WaveForm(numOfColumns: sampleCount, circleColor: circleColor, highlightColor: highlightColor) 11 | return view 12 | } 13 | 14 | func updateUIView(_ uiView: WaveForm, context: Context) { 15 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 16 | uiView.drawColumns(with: columns) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Extensions/CGColor+.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | 3 | extension CGColor { 4 | func toHex() -> String? { 5 | guard let components = components, components.count >= 3 else { 6 | return nil 7 | } 8 | 9 | let r = components[0] 10 | let g = components[1] 11 | let b = components[2] 12 | 13 | // Handle alpha if it exists 14 | var a: CGFloat = 1.0 15 | if components.count >= 4 { 16 | a = components[3] 17 | } 18 | 19 | if a != 1.0 { 20 | // Return RGBA hex string 21 | return String(format: "#%02X%02X%02X%02X", 22 | Int(r * 255), 23 | Int(g * 255), 24 | Int(b * 255), 25 | Int(a * 255)) 26 | } else { 27 | // Return RGB hex string 28 | return String(format: "#%02X%02X%02X", 29 | Int(r * 255), 30 | Int(g * 255), 31 | Int(b * 255)) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Extensions/Color+Hex.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Color { 4 | init(hex: String) { 5 | let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) 6 | var int: UInt64 = 0 7 | 8 | Scanner(string: hex).scanHexInt64(&int) 9 | 10 | let a, r, g, b: UInt64 11 | switch hex.count { 12 | case 3: // RGB (12-bit) 13 | (a, r, g, b) = (255, 14 | (int >> 8) * 17, 15 | (int >> 4 & 0xF) * 17, 16 | (int & 0xF) * 17) 17 | case 6: // RGB (24-bit) 18 | (a, r, g, b) = (255, 19 | int >> 16, 20 | int >> 8 & 0xFF, 21 | int & 0xFF) 22 | case 8: // ARGB (32-bit) 23 | (a, r, g, b) = (int >> 24, 24 | int >> 16 & 0xFF, 25 | int >> 8 & 0xFF, 26 | int & 0xFF) 27 | default: 28 | (a, r, g, b) = (255, 0, 0, 0) // 기본값: 검정색 29 | } 30 | 31 | self.init( 32 | .sRGB, 33 | red: Double(r) / 255, 34 | green: Double(g) / 255, 35 | blue: Double(b) / 255, 36 | opacity: Double(a) / 255 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Extensions/Font+.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Font { 4 | static func doHyeon(size: CGFloat) -> Font { 5 | return .custom("DoHyeon-Regular", size: size) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Extensions/String+.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension String { 4 | func hexToCGColor() -> CGColor? { 5 | var hexString = self.trimmingCharacters(in: .whitespacesAndNewlines) 6 | if hexString.hasPrefix("#") { 7 | hexString.removeFirst() 8 | } 9 | 10 | guard hexString.count == 6 || hexString.count == 8 else { 11 | return nil 12 | } 13 | 14 | var hexInt: UInt64 = 0 15 | let scanner = Scanner(string: hexString) 16 | guard scanner.scanHexInt64(&hexInt) else { 17 | return nil 18 | } 19 | 20 | let red, green, blue, alpha: CGFloat 21 | if hexString.count == 8 { 22 | red = CGFloat((hexInt >> 24) & 0xFF) / 255.0 23 | green = CGFloat((hexInt >> 16) & 0xFF) / 255.0 24 | blue = CGFloat((hexInt >> 8) & 0xFF) / 255.0 25 | alpha = CGFloat(hexInt & 0xFF) / 255.0 26 | } else { 27 | red = CGFloat((hexInt >> 16) & 0xFF) / 255.0 28 | green = CGFloat((hexInt >> 8) & 0xFF) / 255.0 29 | blue = CGFloat(hexInt & 0xFF) / 255.0 30 | alpha = 1.0 31 | } 32 | 33 | return CGColor(colorSpace: CGColorSpaceCreateDeviceRGB(), components: [red, green, blue, alpha]) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Extensions/UIColor+Hex.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIColor { 4 | convenience init?(hex: String?) { 5 | guard let hex = hex else { 6 | return nil 7 | } 8 | var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() 9 | 10 | if hexSanitized.hasPrefix("#") { 11 | hexSanitized.remove(at: hexSanitized.startIndex) 12 | } 13 | 14 | let length = hexSanitized.count 15 | guard length == 6 || length == 8 else { 16 | return nil 17 | } 18 | 19 | var rgbValue: UInt64 = 0 20 | Scanner(string: hexSanitized).scanHexInt64(&rgbValue) 21 | 22 | var red, green, blue, alpha: CGFloat 23 | 24 | if length == 6 { 25 | red = CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0 26 | green = CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0 27 | blue = CGFloat(rgbValue & 0x0000FF) / 255.0 28 | alpha = 1.0 29 | } else { 30 | red = CGFloat((rgbValue & 0xFF000000) >> 24) / 255.0 31 | green = CGFloat((rgbValue & 0x00FF0000) >> 16) / 255.0 32 | blue = CGFloat((rgbValue & 0x0000FF00) >> 8) / 255.0 33 | alpha = CGFloat(rgbValue & 0x000000FF) / 255.0 34 | } 35 | 36 | self.init(red: red, green: green, blue: blue, alpha: alpha) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Extensions/UIFont+.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | enum FontName: String { 4 | case dohyeon = "Dohyeon-Regular" 5 | } 6 | 7 | extension UIFont { 8 | static func font(_ style: FontName = .dohyeon, ofSize size: CGFloat) -> UIFont { 9 | guard let customFont = UIFont(name: style.rawValue, size: size) else { 10 | return UIFont.systemFont(ofSize: size) 11 | } 12 | return customFont 13 | } 14 | 15 | static func font(_ style: FontName = .dohyeon, forTextStyle textStyle: UIFont.TextStyle) -> UIFont { 16 | let size = UIFont.preferredFont(forTextStyle: textStyle).pointSize 17 | guard let customFont = UIFont(name: style.rawValue, size: size) else { 18 | return UIFont.systemFont(ofSize: size) 19 | } 20 | return customFont 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Extensions/UIImage+Flip.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIImage { 4 | func rotate(radians: CGFloat) -> UIImage? { 5 | let size = self.size 6 | let transform = CGAffineTransform(rotationAngle: radians) 7 | let rotatedSize = CGRect(origin: .zero, size: size).applying(transform).integral.size 8 | UIGraphicsBeginImageContextWithOptions(rotatedSize, false, self.scale) 9 | 10 | guard let context = UIGraphicsGetCurrentContext() else { return nil } 11 | context.translateBy(x: rotatedSize.width / 2, y: rotatedSize.height / 2) 12 | context.rotate(by: radians) 13 | self.draw(in: CGRect(x: -size.width / 2, y: -size.height / 2, width: size.width, height: size.height)) 14 | let rotatedImage = UIGraphicsGetImageFromCurrentImageContext() 15 | 16 | UIGraphicsEndImageContext() 17 | 18 | return rotatedImage 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Extensions/UIView+.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIView { 4 | /// 버튼에 지정된 그림자를 추가하는 메서드 5 | func setShadow(color: UIColor = .asShadow, width: CGFloat = 4, height: CGFloat = 4, radius: CGFloat = 0, opacity: Float = 1) { 6 | self.layer.shadowColor = color.cgColor 7 | self.layer.shadowOpacity = opacity 8 | self.layer.shadowOffset = CGSize(width: width, height: height) 9 | self.layer.shadowRadius = radius 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Extensions/UIViewController+present.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIViewController { 4 | func presentAlert(_ viewcontrollerToPresent: ASAlertController) { 5 | present(viewcontrollerToPresent, animated: true, completion: nil) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import ASCacheKit 2 | import ASContainer 3 | import ASLogKit 4 | import ASNetworkKit 5 | import ASRepository 6 | import ASRepositoryProtocol 7 | import Firebase 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | var window: UIWindow? 12 | 13 | func scene(_ scene: UIScene, 14 | willConnectTo _: UISceneSession, 15 | options connectionOptions: UIScene.ConnectionOptions) 16 | { 17 | guard let windowScene = (scene as? UIWindowScene) else { return } 18 | FirebaseApp.configure() 19 | ASFirebaseAuth.configure() 20 | assembleDependencies() 21 | var inviteCode = "" 22 | 23 | if let url = connectionOptions.urlContexts.first?.url { 24 | let components = URLComponents(url: url, resolvingAgainstBaseURL: true) 25 | if let roomNumber = components?.queryItems?.first(where: { item in 26 | item.name == "roomnumber" 27 | })?.value { 28 | inviteCode = roomNumber 29 | } 30 | } 31 | window = UIWindow(windowScene: windowScene) 32 | 33 | let onboardingVM = OnboardingViewModel( 34 | avatarRepository: DIContainer.shared.resolve(AvatarRepositoryProtocol.self), 35 | roomActionRepository: DIContainer.shared.resolve(RoomActionRepositoryProtocol.self), 36 | dataDownloadRepository: DIContainer.shared.resolve(DataDownloadRepositoryProtocol.self) 37 | ) 38 | let onboardingVC = OnboardingViewController(viewmodel: onboardingVM, inviteCode: inviteCode) 39 | let navigationController = UINavigationController(rootViewController: onboardingVC) 40 | navigationController.navigationBar.isHidden = true 41 | navigationController.interactivePopGestureRecognizer?.isEnabled = false 42 | window?.rootViewController = navigationController 43 | window?.makeKeyAndVisible() 44 | } 45 | 46 | func sceneDidDisconnect(_: UIScene) { 47 | let firebaseManager = DIContainer.shared.resolve(ASFirebaseAuthProtocol.self) 48 | Task { 49 | do { 50 | try await firebaseManager.signOut() 51 | } catch { 52 | Logger.error(error.localizedDescription) 53 | } 54 | } 55 | } 56 | 57 | private func assembleDependencies() { 58 | DIContainer.shared.addAssemblies([CacheAssembly(), NetworkAssembly(), RepsotioryAssembly()]) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Utils/Debouncer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class Debouncer { 4 | private let delay: TimeInterval 5 | private var workItem: DispatchWorkItem? 6 | 7 | init(delay: TimeInterval) { 8 | self.delay = delay 9 | } 10 | 11 | func debounce(action: @escaping () -> Void) { 12 | workItem?.cancel() 13 | workItem = DispatchWorkItem { action() } 14 | DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem!) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Utils/NickNameGenerator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum NickNameGenerator { 4 | static func generate() -> String { 5 | let staffName = ["틀틀보", "도덕적인 삶", "쏘니", "로얄 iOS핑"] 6 | let adjectives = ["부드러운", "도덕적인", "뽀송뽀송한", "축축한", "화가 많은", "건전한", "반짝반짝한"] 7 | let nouns = ["삶", "초코칩", "머리", "두피", "개발자", "좀도둑", "사냥꾼", "취객", "루돌프", "사부님", "강아지"] 8 | 9 | if Int.random(in: 0 ..< 1000) == 0 { 10 | return staffName.randomElement() ?? "멋진 닉네임" 11 | } 12 | 13 | if let adjective = adjectives.randomElement(), 14 | let noun = nouns.randomElement() { 15 | return "\(adjective) \(noun)" 16 | } 17 | 18 | return "멋진 닉네임" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct LobbyView: View { 4 | @ObservedObject var viewModel: LobbyViewModel 5 | @State var isPresented = false 6 | @Environment(\.dismiss) var dismiss 7 | var body: some View { 8 | VStack { 9 | ScrollView(.horizontal) { 10 | HStack(alignment: .top, spacing: 16) { 11 | ForEach(0 ..< viewModel.playerMaxCount) { index in 12 | if index < viewModel.players.count { 13 | let player = viewModel.players[index] 14 | ProfileView( 15 | imagePublisher: { url in 16 | await viewModel.getAvatarData(url: url) 17 | }, 18 | name: player.nickname, 19 | isHost: player.id == viewModel.host?.id, 20 | imageUrl: player.avatarUrl 21 | ) 22 | } else { 23 | ProfileView( 24 | imagePublisher: { url in 25 | await viewModel.getAvatarData(url: url) 26 | }, 27 | name: nil, 28 | isHost: false, 29 | imageUrl: nil 30 | ) 31 | } 32 | } 33 | } 34 | .padding(.horizontal, 24) 35 | .padding(.top, 20) 36 | .padding(.bottom, 12) 37 | } 38 | VStack { 39 | if viewModel.isHost { 40 | GeometryReader { reader in 41 | SnapperView(size: reader.size, currentMode: $viewModel.mode) 42 | } 43 | } else { 44 | GeometryReader { geometry in 45 | ModeView(modeInfo: viewModel.mode, width: geometry.size.width * 0.85) 46 | .frame(maxWidth: .infinity, alignment: .center) 47 | } 48 | } 49 | } 50 | } 51 | .background(Color.asLightGray) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/RecordingPanel/AudioButtonState.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | enum AudioButtonState { 4 | case playing, recording, idle 5 | 6 | var symbol: UIImage { 7 | switch self { 8 | case .playing: UIImage(systemName: "stop.fill") ?? UIImage() 9 | case .recording: UIImage(systemName: "circle.fill") ?? UIImage() 10 | case .idle: UIImage(systemName: "play.fill") ?? UIImage() 11 | } 12 | } 13 | 14 | var color: UIColor { 15 | switch self { 16 | case .recording: .systemRed 17 | default: .white 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultTableViewDiffableDataSource.swift: -------------------------------------------------------------------------------- 1 | import ASEntity 2 | import Foundation 3 | import SwiftUI 4 | import UIKit 5 | 6 | struct ResultTableViewItem: Hashable, Sendable { 7 | let record: MappedRecord? 8 | let submit: MappedAnswer? 9 | } 10 | 11 | final class HummingResultTableViewDiffableDataSource: UITableViewDiffableDataSource { 12 | init(tableView: UITableView) { 13 | super.init(tableView: tableView) { tableView, indexPath, item in 14 | let cell = UITableViewCell() 15 | cell.backgroundColor = .clear 16 | switch indexPath.section { 17 | case 0: 18 | guard let record = item.record else { return cell } 19 | cell.contentConfiguration = UIHostingConfiguration { 20 | SpeechBubbleCell( 21 | row: indexPath.row, 22 | messageType: .record(record) 23 | ) 24 | .padding(.horizontal, 16) 25 | } 26 | 27 | case 1: 28 | guard let submit = item.submit else { return cell } 29 | let recordsCount = tableView.numberOfRows(inSection: 0) 30 | cell.contentConfiguration = UIHostingConfiguration { 31 | SpeechBubbleCell( 32 | row: recordsCount, 33 | messageType: .music(submit) 34 | ) 35 | .padding(.horizontal, 16) 36 | } 37 | default: 38 | return cell 39 | } 40 | 41 | return cell 42 | } 43 | } 44 | 45 | func applySnapshot(_ result: Result) { 46 | let records = result.records 47 | let submit = result.submit 48 | 49 | var snapshot = NSDiffableDataSourceSnapshot() 50 | 51 | snapshot.appendSections([0, 1]) 52 | 53 | snapshot.appendItems(records.map { 54 | ResultTableViewItem(record: $0, submit: nil) 55 | }, toSection: 0) 56 | 57 | if let submit { 58 | snapshot.appendItems([ResultTableViewItem(record: nil, submit: submit)], 59 | toSection: 1) 60 | } 61 | 62 | apply(snapshot, animatingDifferences: false) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewModel+Entity.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol PlayerInfo { 4 | var playerName: String { get set } 5 | var playerAvatarData: Data { get set } 6 | } 7 | 8 | struct MappedAnswer: Hashable, PlayerInfo { 9 | var artworkData: Data 10 | var previewData: Data 11 | var title: String 12 | var artist: String 13 | var playerName: String 14 | var playerAvatarData: Data 15 | 16 | init(_ artworkData: Data?, _ previewData: Data?, _ title: String?, _ artist: String?, _ playerName: String?, _ playerAvatarData: Data?) { 17 | self.artworkData = artworkData ?? Data() 18 | self.previewData = previewData ?? Data() 19 | self.title = title ?? "" 20 | self.artist = artist ?? "" 21 | self.playerName = playerName ?? "" 22 | self.playerAvatarData = playerAvatarData ?? Data() 23 | } 24 | } 25 | 26 | struct MappedRecord: Hashable, PlayerInfo { 27 | var recordData: Data 28 | var recordAmplitudes: [CGFloat] 29 | var playerName: String 30 | var playerAvatarData: Data 31 | 32 | init(_ recordData: Data?, _ recordAmplitudes: [CGFloat], _ playerName: String?, _ playerAvatarData: Data?) { 33 | self.recordData = recordData ?? Data() 34 | self.recordAmplitudes = recordAmplitudes 35 | self.playerName = playerName ?? "" 36 | self.playerAvatarData = playerAvatarData ?? Data() 37 | } 38 | } 39 | 40 | enum ResultPhase: Equatable { 41 | case none 42 | case answer 43 | case record(Int) 44 | case submit 45 | 46 | var playOption: PlayType { 47 | switch self { 48 | case .record: .full 49 | default: .partial(time: 10) 50 | } 51 | } 52 | 53 | func audioData(_ result: Result) -> Data? { 54 | switch self { 55 | case .answer: result.answer?.previewData 56 | case let .record(count): result.records[count].recordData 57 | case .submit: result.submit?.previewData 58 | default: nil 59 | } 60 | } 61 | } 62 | 63 | enum PlayType { 64 | case full 65 | case partial(time: Int) 66 | } 67 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/ASMusicItemCell.swift: -------------------------------------------------------------------------------- 1 | import ASEntity 2 | import SwiftUI 3 | 4 | struct ASMusicItemCell: View { 5 | @State private var artworkData: Data? 6 | let music: Music? 7 | let fetchArtwork: (URL?) async -> Data? 8 | 9 | var body: some View { 10 | HStack { 11 | if let artworkData, let uiImage = UIImage(data: artworkData) { 12 | Image(uiImage: uiImage) 13 | .resizable() 14 | .frame(width: 60, height: 60) 15 | .clipShape(RoundedRectangle(cornerRadius: 4)) 16 | .padding(.horizontal, 8) 17 | } else if let music, let artworkColor = music.artworkBackgroundColor { 18 | Rectangle() 19 | .foregroundColor(Color(hex: artworkColor)) 20 | .frame(width: 60, height: 60) 21 | .clipShape(RoundedRectangle(cornerRadius: 4)) 22 | .padding(.horizontal, 8) 23 | } else { 24 | Image(systemName: "music.quarternote.3") 25 | .frame(width: 60, height: 60) 26 | .background(.asSystem) 27 | .clipShape(RoundedRectangle(cornerRadius: 4)) 28 | .padding(.horizontal, 8) 29 | } 30 | VStack(alignment: .leading) { 31 | Text(music?.title ?? "선택된 곡 없음") 32 | .font(.doHyeon(size: 16)) 33 | .lineLimit(1) 34 | Text(music?.artist ?? "아티스트") 35 | .foregroundStyle(.gray) 36 | .font(.doHyeon(size: 16)) 37 | .lineLimit(1) 38 | } 39 | } 40 | .task(id: music) { 41 | artworkData = nil 42 | if let music { 43 | artworkData = await fetchArtwork(music.artworkUrl) 44 | } 45 | } 46 | } 47 | } 48 | 49 | #Preview { 50 | ASMusicItemCell(music: Music()) { _ in nil } 51 | } 52 | -------------------------------------------------------------------------------- /alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/ASSearchBar.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ASSearchBar: View { 4 | @Binding var text: String 5 | var placeHolder: String 6 | 7 | var body: some View { 8 | HStack { 9 | HStack { 10 | Image(systemName: "magnifyingglass") 11 | 12 | TextField(placeHolder, text: $text) 13 | .foregroundColor(.primary) 14 | 15 | if !text.isEmpty { 16 | Button(action: { 17 | self.text = "" 18 | }) { 19 | Image(systemName: "xmark.circle.fill") 20 | } 21 | } else { 22 | EmptyView() 23 | } 24 | } 25 | .padding(8) 26 | .foregroundColor(.secondary) 27 | .background(Color(.secondarySystemBackground)) 28 | .cornerRadius(8) 29 | } 30 | .padding(.horizontal) 31 | } 32 | } 33 | 34 | #Preview { 35 | ASSearchBar(text: .constant(""), placeHolder: "검색하세요") 36 | } 37 | -------------------------------------------------------------------------------- /firebase/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "alsongdalsong-boostcamp" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /firebase/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": [ 3 | { 4 | "source": "functions", 5 | "codebase": "default", 6 | "ignore": [ 7 | "node_modules", 8 | ".git", 9 | "firebase-debug.log", 10 | "firebase-debug.*.log", 11 | "*.local" 12 | ] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /firebase/functions/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.local -------------------------------------------------------------------------------- /firebase/functions/FirebaseAdmin.js: -------------------------------------------------------------------------------- 1 | const admin = require('firebase-admin'); 2 | 3 | admin.initializeApp(); 4 | 5 | module.exports = admin; 6 | -------------------------------------------------------------------------------- /firebase/functions/api/ChangeMode.js: -------------------------------------------------------------------------------- 1 | // TODO : 모드 변경 API 입니다. 2 | // fireStore database의 rooms/{roomNumber}/mode를 업데이트 합니다. 3 | 4 | const { onRequest } = require('firebase-functions/v2/https'); 5 | const { getUserData } = require('../common/RoomHelper.js'); 6 | const admin = require('../FirebaseAdmin.js'); 7 | 8 | /** 9 | * 방 참여를 요청하는 API 10 | * @param roomNumber - 방 번호 11 | * @param playerId - 참여자 id 12 | * @param mode - 게임 모드 13 | * @returns playerData 14 | */ 15 | module.exports.changeMode = onRequest({ region: 'asia-southeast1' }, async (req, res) => { 16 | if (req.method !== 'POST') { 17 | return res.status(405).json({ error: 'Only POST requests are accepted' }); 18 | } 19 | const { roomNumber, userId, mode } = req.body; 20 | 21 | if (!roomNumber || !userId || !mode) { 22 | return res.status(400).json({ error: 'Room number, user ID, mode are required' }); 23 | } 24 | 25 | try { 26 | const roomRef = admin.firestore().collection('rooms').doc(roomNumber); 27 | const roomSnapshot = await roomRef.get(); 28 | 29 | if (!roomSnapshot.exists) { 30 | return res.status(404).json({ error: 'Room not found' }); 31 | } 32 | 33 | const roomData = roomSnapshot.data(); 34 | 35 | if (roomData.host === userId) { 36 | return res.status(400).json({ error: 'is not host' }); 37 | } 38 | 39 | const userData = await getUserData(userId); 40 | if (!userData) { 41 | return res.status(404).json({ error: 'User not found' }); 42 | } 43 | 44 | const validModes = ['humming', 'harmony', 'sync', 'instant', 'tts']; 45 | if (!validModes.includes(mode)) { 46 | return res.status(400).json({ error: 'Invalid mode' }); 47 | } 48 | 49 | await roomRef.update({ 50 | mode: mode, 51 | }); 52 | 53 | res.status(200).json({ success: true }); 54 | } catch (error) { 55 | console.error('Join room error:', error); 56 | res.status(500).json({ error: 'Failed to join room' }); 57 | } 58 | }); 59 | -------------------------------------------------------------------------------- /firebase/functions/api/ChangeRecordOrder.js: -------------------------------------------------------------------------------- 1 | // TODO : RecordOrder 변경 API 입니다. 2 | // fireStore database의 rooms/{roomNumber}/RecordOrder를 업데이트 합니다. 3 | 4 | const { onRequest } = require('firebase-functions/v2/https'); 5 | const { getUserData } = require('../common/RoomHelper.js'); 6 | const admin = require('../FirebaseAdmin.js'); 7 | 8 | /** 9 | * RecordOrder 변경 API 10 | * @param roomNumber - 방 번호 11 | * @param playerId - 참여자 id 12 | * @returns playerData 13 | */ 14 | module.exports.changeRecordOrder = onRequest({ region: 'asia-southeast1' }, async (req, res) => { 15 | if (req.method !== 'POST') { 16 | return res.status(405).json({ error: 'Only POST requests are accepted' }); 17 | } 18 | const { roomNumber, userId } = req.body; 19 | 20 | if (!roomNumber || !userId) { 21 | return res.status(400).json({ error: 'Room number, user ID are required' }); 22 | } 23 | 24 | try { 25 | const roomRef = admin.firestore().collection('rooms').doc(roomNumber); 26 | const roomSnapshot = await roomRef.get(); 27 | 28 | if (!roomSnapshot.exists) { 29 | return res.status(404).json({ error: 'Room not found' }); 30 | } 31 | 32 | const roomData = roomSnapshot.data(); 33 | 34 | if (roomData.host === userId) { 35 | return res.status(400).json({ error: 'is not host' }); 36 | } 37 | 38 | const userData = await getUserData(userId); 39 | if (!userData) { 40 | return res.status(404).json({ error: 'User not found' }); 41 | } 42 | 43 | const currentRecordOrder = roomData.recordOrder + 1 44 | 45 | await roomRef.update({ 46 | recordOrder: currentRecordOrder, 47 | }); 48 | 49 | res.status(200).json({ success: true }); 50 | } catch (error) { 51 | console.error('modify RecordOrder error:', error); 52 | res.status(500).json({ error: 'Failed to modify recordOrder' }); 53 | } 54 | }); 55 | -------------------------------------------------------------------------------- /firebase/functions/api/CreateRoom.js: -------------------------------------------------------------------------------- 1 | // TODO : 방 생성 API입니다. 2 | // fireStore database의 rooms/{roomNumber} 에 room 데이터를 생성합니다. 3 | 4 | const { onRequest } = require('firebase-functions/v2/https'); 5 | const { generateRoomNumber, getUserData, createRoomData } = require('../common/RoomHelper.js'); 6 | const admin = require('../FirebaseAdmin.js'); 7 | 8 | /** 9 | * 방 생성을 요청하는 API 10 | * @param playerId - 호스트 id 11 | * @returns roomNumber - 생성된 방 번호 12 | */ 13 | 14 | module.exports.createRoom = onRequest({ region: 'asia-southeast1' }, async (req, res) => { 15 | if (req.method !== 'POST') { 16 | return res.status(405).json({ error: 'Only POST requests are accepted' }); 17 | } 18 | 19 | const { hostID } = req.body; 20 | if (!hostID) { 21 | return res.status(400).json({ error: 'Host ID is required' }); 22 | } 23 | 24 | try { 25 | const roomNumber = await generateRoomNumber(); 26 | const hostData = await getUserData(hostID); 27 | 28 | if (!hostData) { 29 | return res.status(404).json({ error: 'Host not found' }); 30 | } 31 | 32 | const roomData = createRoomData(roomNumber, hostData); 33 | await admin.firestore().collection('rooms').doc(roomNumber).set(roomData); 34 | 35 | res.status(200).json({ number: roomNumber }); 36 | } catch (error) { 37 | res.status(500).json({ error: 'Failed to create room' }); 38 | } 39 | }); 40 | -------------------------------------------------------------------------------- /firebase/functions/api/ExitRoom.js: -------------------------------------------------------------------------------- 1 | // TODO: 방 나가기 로직 구현 2 | // 만약 호스트가 나간 경우 players중 남은 사람 중 랜덤으로 호스트를 지정한다. 3 | // 방에 남은 사람이 없을 경우 방을 삭제 한다. 4 | 5 | const { onRequest } = require('firebase-functions/v2/https'); 6 | const { getUserData } = require('../common/RoomHelper.js'); 7 | const admin = require('../FirebaseAdmin.js'); 8 | /** 9 | * 방 나가기를 요청하는 API 10 | * @param roomNumber - 방 번호 11 | * @param playerId - 나가는 참여자 id 12 | */ 13 | 14 | module.exports.exitRoom = onRequest({ region: 'asia-southeast1' }, async (req, res) => { 15 | if (req.method !== 'POST') { 16 | return res.status(405).json({ error: 'Only POST requests are accepted' }); 17 | } 18 | 19 | const { roomNumber, userId } = req.body; 20 | if (!roomNumber || !userId) { 21 | return res.status(400).json({ error: 'Room number and user ID are required' }); 22 | } 23 | 24 | try { 25 | const roomRef = admin.firestore().collection('rooms').doc(roomNumber); 26 | const roomData = await roomRef.get(); 27 | const players = roomData.data().players; 28 | 29 | if (!roomData.exists) { 30 | return res.status(404).json({ error: 'Room not found' }); 31 | } 32 | 33 | if (roomData.data().host.id === userId) { 34 | if (players.length > 1) { 35 | const newHost = players.find((player) => player.id !== userId); 36 | await roomRef.update({ 37 | host: newHost, 38 | players: players.filter((player) => player.id !== userId), 39 | }); 40 | } else { 41 | await roomRef.delete(); 42 | } 43 | } else { 44 | const updatedPlayers = players.filter((player) => player.id !== userId); 45 | await roomRef.update({ 46 | players: updatedPlayers, 47 | }); 48 | } 49 | res.status(200).json({ message: 'Successfully exited the room' }); 50 | } catch (error) { 51 | console.error('Exit room error:', error); 52 | res.status(500).json({ error: 'Failed to exit room' }); 53 | } 54 | }); 55 | -------------------------------------------------------------------------------- /firebase/functions/api/GetUserAvatar.js: -------------------------------------------------------------------------------- 1 | // TODO: 유저 프로필 이미지 url 리스트를 가져오는 API 입니다. 2 | // avatar/images/ 에 있는 이미지 url 리스트 들을 가져옵니다. 3 | 4 | const { onRequest } = require('firebase-functions/v2/https'); 5 | const admin = require('../FirebaseAdmin.js'); 6 | 7 | /** 8 | * 유저 프로필 이미지 url 리스트를 가져오는 API 입니다. 9 | * @returns 이미지 url 리스트 10 | */ 11 | module.exports.getUserAvatar = onRequest({ region: 'asia-southeast1' }, async (req, res) => { 12 | res.status(200).json({ message: 'Get User Avatar' }); 13 | }); 14 | -------------------------------------------------------------------------------- /firebase/functions/api/JoinRoom.js: -------------------------------------------------------------------------------- 1 | // TODO : 방 참여 API입니다. 2 | // fireStore database의 rooms/{roomNumber}/players 에 player 데이터를 추가합니다. 3 | 4 | const { onRequest } = require('firebase-functions/v2/https'); 5 | const { getUserData } = require('../common/RoomHelper.js'); 6 | const admin = require('../FirebaseAdmin.js'); 7 | const { FieldValue } = require('firebase-admin/firestore'); 8 | 9 | /** 10 | * 방 참여를 요청하는 API 11 | * @param roomNumber - 방 번호 12 | * @param playerId - 참여자 id 13 | * @returns playerData 14 | */ 15 | module.exports.joinRoom = onRequest({ region: 'asia-southeast1' }, async (req, res) => { 16 | if (req.method !== 'POST') { 17 | return res.status(405).json({ error: 'Only POST requests are accepted' }); 18 | } 19 | const { roomNumber, userId } = req.body; 20 | if (!roomNumber || !userId) { 21 | return res.status(400).json({ error: 'Room number and user ID are required' }); 22 | } 23 | 24 | try { 25 | const roomRef = admin.firestore().collection('rooms').doc(roomNumber); 26 | const roomSnapshot = await roomRef.get(); 27 | 28 | if (!roomSnapshot.exists) { 29 | return res.status(404).json({ error: 'Room not found' }); 30 | } 31 | 32 | const roomData = roomSnapshot.data(); 33 | const playerExists = roomData.players.some((player) => player.id === userId); 34 | const inGame = roomData.status !== null; 35 | 36 | if (inGame) { 37 | return res.status(452).json({ error: 'Game has already started in this room' }); 38 | 39 | if (playerExists) { 40 | return res.status(400).json({ error: 'User already in the room' }); 41 | } 42 | 43 | const userData = await getUserData(userId); 44 | if (!userData) { 45 | return res.status(404).json({ error: 'User not found' }); 46 | } 47 | 48 | const player = { 49 | id: userId, 50 | avatarUrl: userData.avatarUrl || '', 51 | nickname: userData.nickname || '', 52 | score: userData.score || 0, 53 | order: 0, 54 | }; 55 | 56 | await roomRef.update({ 57 | players: FieldValue.arrayUnion(player), 58 | }); 59 | 60 | res.status(200).json({ number: roomNumber }); 61 | } catch (error) { 62 | console.error('Join room error:', error); 63 | res.status(500).json({ error: 'Failed to join room' }); 64 | } 65 | }); 66 | -------------------------------------------------------------------------------- /firebase/functions/api/ResetGame.js: -------------------------------------------------------------------------------- 1 | // TODO: 게임 리셋 요청하는 API입니다. 2 | // rooms/{roomNumber}의 status를 초기화합니다. 3 | // 방의 호스트가 호출했는지 검사하는 로직이 필요합니다. 4 | 5 | const { onRequest } = require('firebase-functions/v2/https'); 6 | const admin = require('../FirebaseAdmin.js'); 7 | const { FieldValue } = require('firebase-admin/firestore'); 8 | 9 | /** 10 | * 게임 리셋 요청을 처리하는 HTTPS 요청. 11 | * @param roomNumber - 방 번호 12 | * @param userId - 호스트 ID 13 | * @returns JSON 응답 14 | */ 15 | module.exports.resetGame = onRequest({ region: 'asia-southeast1' }, async (req, res) => { 16 | if (req.method !== 'POST') { 17 | return res.status(405).json({ error: 'Only POST requests are accepted' }); 18 | } 19 | 20 | const { roomNumber, userId } = req.query; 21 | const roomRef = admin.firestore().collection('rooms').doc(roomNumber); 22 | 23 | try { 24 | const roomSnapshot = await roomRef.get(); 25 | if (!roomSnapshot.exists) { 26 | return res.status(404).json({ error: 'Room not found' }); 27 | } 28 | 29 | const roomData = roomSnapshot.data(); 30 | 31 | // 요청한 유저가 방의 호스트인지 확인 32 | if (roomData.host.id !== userId) { 33 | return res.status(403).json({ error: 'Only the host can reset the game' }); 34 | } 35 | 36 | const updatedRoomData = { 37 | ...roomData, 38 | round: 0, 39 | status: FieldValue.delete(), 40 | records: [], 41 | answers: [], 42 | dueTime: FieldValue.delete(), 43 | selectedRecords: [], 44 | submits: [], 45 | recordOrder: FieldValue.delete(), 46 | }; 47 | 48 | // Firestore에 업데이트 49 | await roomRef.set(updatedRoomData, { merge: true }); 50 | 51 | return res.status(200).json({ success: true }); 52 | } catch (error) { 53 | console.error('Error resetting game:', error); 54 | return res.status(500).json({ error: 'Failed to reset the game' }); 55 | } 56 | }); 57 | -------------------------------------------------------------------------------- /firebase/functions/api/SetAnswer.js: -------------------------------------------------------------------------------- 1 | // TODO: 문제를 제출하고, 방에 저장하는 API입니다. 2 | // 문제는 rooms/{roomNumber}/answers 에 저장합니다. 3 | 4 | /** 5 | * 제출자가 문제를 정하고 방에 저장하는 API입니다.. 6 | * @param roomNumber - 방 정보 7 | * @param playerId - 제출자 id 8 | * @param Answer - 제출된 문제 9 | * @returns Boolean 10 | */ 11 | 12 | module.exports.submitQuestion = onRequest({ region: 'asia-southeast1' }, async (req, res) => { 13 | res.status(200).json({ message: 'Submit Question' }); 14 | }); 15 | -------------------------------------------------------------------------------- /firebase/functions/api/StartGame.js: -------------------------------------------------------------------------------- 1 | const { onRequest } = require('firebase-functions/v2/https'); 2 | const admin = require('../FirebaseAdmin.js'); 3 | 4 | /** 5 | * 게임을 시작 요청을 하는 HTTPS requests. 6 | * @param roomNumber - 방 정보 7 | * @param playerId - 호스트 id 8 | * @returns status message 9 | */ 10 | module.exports.startGame = onRequest({ region: 'asia-southeast1' }, async (req, res) => { 11 | if (req.method !== 'POST') { 12 | return res.status(405).json({ error: 'Only POST requests are accepted' }); 13 | } 14 | 15 | const { roomNumber, userId } = req.body; 16 | const roomRef = admin.firestore().collection('rooms').doc(roomNumber); 17 | 18 | try { 19 | const roomSnapshot = await roomRef.get(); 20 | if (!roomSnapshot.exists) { 21 | return res.status(404).json({ error: 'Room not found' }); 22 | } 23 | 24 | const roomData = roomSnapshot.data(); 25 | 26 | // 요청한 유저가 방의 호스트인지 확인 27 | if (roomData.host.id !== userId) { 28 | return res.status(403).json({ error: 'Only the host can start the game' }); 29 | } 30 | 31 | if (roomData.mode === 'humming') { 32 | // players 배열에서 order 랜덤으로 부여 33 | const players = roomData.players; 34 | const orderPool = [...Array(players.length).keys()]; // [0, 1, 2, ..., players.length - 1] 35 | 36 | const shuffledOrders = shuffle(orderPool); 37 | 38 | const updatedPlayers = players.map((player, index) => ({ 39 | ...player, 40 | order: shuffledOrders[index], // 랜덤으로 부여된 order 값 41 | })); 42 | 43 | // 현재 시간에서 1분 뒤로 설정 44 | const currentTime = new Date(); 45 | const dueTime = new Date(currentTime.getTime() + 1 * 60 * 1000); // 단위가 ms임 46 | // TODO Player Order 설정 47 | // TODO 게임 모드에 따른 라운드 설정 및 status 변경, records 초기화 48 | await roomRef.update({ 49 | players: updatedPlayers, 50 | status: 'humming', 51 | round: 0, 52 | recordOrder: 0, 53 | dueTime: dueTime, 54 | }); 55 | 56 | res.status(200).json({ success: true }); 57 | } else { 58 | res.status(400).json({ error: 'Invalid mode' }); 59 | } 60 | } catch (error) { 61 | res.status(500).json({ error: 'Failed to start game' }); 62 | } 63 | }); 64 | 65 | /** 66 | * 배열을 랜덤으로 섞는 유틸리티 함수 67 | * @param {Array} array - 섞을 배열 68 | * @returns {Array} - 랜덤으로 섞인 배열 69 | */ 70 | function shuffle(array) { 71 | for (let i = array.length - 1; i > 0; i--) { 72 | const j = Math.floor(Math.random() * (i + 1)); 73 | [array[i], array[j]] = [array[j], array[i]]; 74 | } 75 | return array; 76 | } 77 | -------------------------------------------------------------------------------- /firebase/functions/api/SubmitAnswer.js: -------------------------------------------------------------------------------- 1 | // TODO : 정답 제출 API입니다. 2 | // fireStore database의 rooms/{roomNumber} 에 room 데이터를 생성합니다. 3 | const { FieldValue } = require('firebase-admin/firestore'); 4 | const { onRequest } = require('firebase-functions/v2/https'); 5 | const { getUserData } = require('../common/RoomHelper.js'); 6 | const admin = require('../FirebaseAdmin.js'); 7 | /** 8 | * 방 생성을 요청하는 API 9 | * @param playerId - 호스트 id 10 | * @returns roomNumber - 생성된 방 번호 11 | */ 12 | module.exports.submitAnswer = onRequest({ region: 'asia-southeast1' }, async (req, res) => { 13 | if (req.method !== 'POST') { 14 | return res.status(405).json({ error: 'Only POST requests are accepted' }); 15 | } 16 | const { userId, roomNumber } = req.query; 17 | if (!userId) { 18 | console.log('유저 아이디 없음'); 19 | return res.status(400).json({ error: 'User ID is required' }); 20 | } 21 | if (!roomNumber) { 22 | console.log('방 번호 없음'); 23 | return res.status(400).json({ error: 'Room Number is required' }); 24 | } 25 | try { 26 | const roomRef = admin.firestore().collection('rooms').doc(roomNumber); 27 | const roomSnapshot = await roomRef.get(); 28 | const roomData = roomSnapshot.data(); 29 | const userData = roomData.players.find((player) => player.id === userId); 30 | const playersCount = roomData.players.length; 31 | const submitCount = roomData.submits.length; 32 | if (!userData) { 33 | return res.status(404).json({ error: 'plyer Data not found' }); 34 | } 35 | const answer = { 36 | player: userData, 37 | music: req.body, 38 | }; 39 | if (submitCount + 1 === playersCount) { 40 | await roomRef.update({ 41 | submits: FieldValue.arrayUnion(answer), 42 | status: 'result', 43 | }); 44 | } else { 45 | await roomRef.update({ 46 | submits: FieldValue.arrayUnion(answer), 47 | }); 48 | } 49 | 50 | res.status(200).json({ status: 'success' }); 51 | } catch (error) { 52 | console.log('에러러', error); 53 | res.status(500).json({ error: 'Failed to create room' }); 54 | } 55 | }); 56 | -------------------------------------------------------------------------------- /firebase/functions/api/SubmitAnswerV2.js: -------------------------------------------------------------------------------- 1 | // TODO : 정답 제출 API입니다. 2 | // fireStore database의 rooms/{roomNumber} 에 room 데이터를 생성합니다. 3 | const { FieldValue } = require('firebase-admin/firestore'); 4 | const { onRequest } = require('firebase-functions/v2/https'); 5 | const admin = require('../FirebaseAdmin.js'); 6 | 7 | /** 8 | * 정답 제출을 처리하는 API 9 | * @param userId - 사용자 ID 10 | * @param roomNumber - 방 번호 11 | * @returns 상태 메시지 12 | */ 13 | module.exports.submitAnswerV2 = onRequest({ region: 'asia-southeast1' }, async (req, res) => { 14 | if (req.method !== 'POST') { 15 | return res.status(405).json({ error: 'Only POST requests are accepted' }); 16 | } 17 | 18 | const { userId, roomNumber } = req.query; 19 | 20 | if (!userId) { 21 | console.log('유저 아이디 없음'); 22 | return res.status(400).json({ error: 'User ID is required' }); 23 | } 24 | 25 | if (!roomNumber) { 26 | console.log('방 번호 없음'); 27 | return res.status(400).json({ error: 'Room Number is required' }); 28 | } 29 | 30 | try { 31 | const roomRef = admin.firestore().collection('rooms').doc(roomNumber); 32 | 33 | // 트랜잭션 시작 34 | await admin.firestore().runTransaction(async (transaction) => { 35 | const roomSnapshot = await transaction.get(roomRef); 36 | const roomData = roomSnapshot.data(); 37 | 38 | if (!roomData) { 39 | throw new Error('Room not found'); 40 | } 41 | 42 | const userData = roomData.players.find((player) => player.id === userId); 43 | 44 | if (!userData) { 45 | throw new Error('Player data not found'); 46 | } 47 | 48 | const playersCount = roomData.players.length; 49 | const submitCount = roomData.submits ? roomData.submits.length : 0; 50 | 51 | // 이미 제출한 사용자인지 확인 52 | const hasSubmitted = roomData.submits ? roomData.submits.some((submit) => submit.player.id === userId) : false; 53 | 54 | if (hasSubmitted) { 55 | throw new Error('User has already submitted an answer'); 56 | } 57 | 58 | const answer = { 59 | player: userData, 60 | music: req.body, 61 | }; 62 | 63 | // 제출 업데이트 64 | transaction.update(roomRef, { 65 | submits: FieldValue.arrayUnion(answer), 66 | }); 67 | 68 | // 모든 플레이어가 제출했는지 확인 69 | if (submitCount + 1 === playersCount) { 70 | transaction.update(roomRef, { 71 | status: 'result', 72 | }); 73 | } 74 | }); 75 | 76 | res.status(200).json({ status: 'success' }); 77 | } catch (error) { 78 | console.error('에러 발생:', error); 79 | res.status(500).json({ error: error.message }); 80 | } 81 | }); 82 | -------------------------------------------------------------------------------- /firebase/functions/common/GameHelper.js: -------------------------------------------------------------------------------- 1 | const admin = require('../FirebaseAdmin.js'); 2 | const firestore = admin.firestore(); 3 | 4 | /** 5 | * 승자 계산 함수 6 | * @returns player - 가장 높은 점수를 가진 플레이어 배열 7 | */ 8 | function calculateWinners() {} 9 | 10 | /** 11 | * 플레이어 순서 결정 함수 12 | * @param mode - 게임 모드 13 | * @param players - 플레이어 배열 14 | * @returns orderedPlayers - 순서가 배분된 플레이어 배열 15 | */ 16 | function determinePlayerOrder(mode, players) {} 17 | 18 | module.exports = { 19 | calculateWinners, 20 | determinePlayerOrder, 21 | }; 22 | -------------------------------------------------------------------------------- /firebase/functions/index.js: -------------------------------------------------------------------------------- 1 | const { createRoom } = require('./api/CreateRoom.js'); 2 | const { joinRoom } = require('./api/JoinRoom.js'); 3 | const { startGame } = require('./api/StartGame.js'); 4 | const { uploadRecord } = require('./api/UploadRecord.js'); 5 | const { exitRoom } = require('./api/ExitRoom.js'); 6 | const { onRemovePlayer, onRemoveRoom } = require('./trigger/onRemovePlayer.js'); 7 | const { changeMode } = require('./api/ChangeMode.js'); 8 | const { submitMusic } = require('./api/SubmitMusic'); 9 | const { submitAnswer } = require('./api/SubmitAnswer'); 10 | const { changeRecordOrder } = require('./api/ChangeRecordOrder.js'); 11 | const { resetGame } = require('./api/ResetGame.js'); 12 | 13 | const { submitMusicV2 } = require('./api/SubmitMusicV2.js'); 14 | const { submitAnswerV2 } = require('./api/SubmitAnswerV2.js'); 15 | const { uploadRecordingV2 } = require('./api/UploadRecordV2.js'); 16 | 17 | // 방 관련 API 18 | exports.createRoom = createRoom; 19 | exports.joinRoom = joinRoom; 20 | exports.startGame = startGame; 21 | exports.exitRoom = exitRoom; 22 | exports.changeMode = changeMode; 23 | exports.onRemoveRoom = onRemoveRoom; 24 | exports.submitMusic = submitMusic; 25 | exports.submitAnswer = submitAnswer; 26 | exports.changeRecordOrder = changeRecordOrder; 27 | exports.resetGame = resetGame; 28 | exports.onRemovePlayer = onRemovePlayer; 29 | exports.startGame = startGame; 30 | exports.uploadRecording = uploadRecord; 31 | 32 | exports.V2 = { 33 | uploadRecording: uploadRecordingV2, 34 | submitMusic: submitMusicV2, 35 | submitAnswer: submitAnswerV2, 36 | }; 37 | ∑ -------------------------------------------------------------------------------- /firebase/functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "description": "Cloud Functions for Firebase", 4 | "scripts": { 5 | "serve": "firebase emulators:start --only functions", 6 | "shell": "firebase functions:shell", 7 | "start": "npm run shell", 8 | "deploy": "firebase deploy --only functions", 9 | "logs": "firebase functions:log" 10 | }, 11 | "engines": { 12 | "node": "18" 13 | }, 14 | "main": "index.js", 15 | "dependencies": { 16 | "body-parser": "^1.20.3", 17 | "express": "^4.21.1", 18 | "express-multipart-file-parser": "^0.1.2", 19 | "firebase-admin": "^12.6.0", 20 | "firebase-functions": "^6.0.1" 21 | }, 22 | "devDependencies": { 23 | "firebase-functions-test": "^3.1.0" 24 | }, 25 | "private": true 26 | } 27 | --------------------------------------------------------------------------------