├── .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 |
--------------------------------------------------------------------------------