├── .github ├── ISSUE_TEMPLATE │ └── 기본-이슈-생성-템플릿.md └── workflows │ ├── CI_BUILD.yml │ └── CI_TEST.yml ├── .gitignore ├── .swiftlint.yml ├── Molio.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── swiftpm │ └── Package.resolved ├── Molio ├── Molio.entitlements ├── Resource │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AlbumCoverSample.imageset │ │ │ ├── Contents.json │ │ │ └── sample.png │ │ ├── AppIcon.appiconset │ │ │ ├── 1024.png │ │ │ ├── 114.png │ │ │ ├── 120.png │ │ │ ├── 180.png │ │ │ ├── 29.png │ │ │ ├── 40.png │ │ │ ├── 57.png │ │ │ ├── 58.png │ │ │ ├── 60.png │ │ │ ├── 80.png │ │ │ ├── 87.png │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Onboarding │ │ │ ├── Contents.json │ │ │ ├── onBoardingThree.imageset │ │ │ │ ├── Contents.json │ │ │ │ ├── onboardingThree.png │ │ │ │ ├── onboardingThree@2x.png │ │ │ │ └── onboardingThree@3x.png │ │ │ ├── onBoardingTwo.imageset │ │ │ │ ├── Contents.json │ │ │ │ ├── onboardingTwo.png │ │ │ │ ├── onboardingTwo@2x.png │ │ │ │ └── onboardingTwo@3x.png │ │ │ ├── onboardingFive.imageset │ │ │ │ ├── Contents.json │ │ │ │ ├── onboardingFive.png │ │ │ │ ├── onboardingFive@2x.png │ │ │ │ └── onboardingFive@3x.png │ │ │ ├── onboardingFour.imageset │ │ │ │ ├── Contents.json │ │ │ │ ├── onboardingFour.png │ │ │ │ ├── onboardingFour@2x.png │ │ │ │ └── onboardingFour@3x.png │ │ │ ├── onboardingOne.imageset │ │ │ │ ├── Contents.json │ │ │ │ ├── onboardingOne.png │ │ │ │ ├── onboardingOne@2x.png │ │ │ │ └── onboardingOne@3x.png │ │ │ └── onboardingSix.imageset │ │ │ │ ├── Contents.json │ │ │ │ ├── onboardingSix.png │ │ │ │ ├── onboardingSix@2x.png │ │ │ │ └── onboardingSix@3x.png │ │ ├── appleMusicLogo.imageset │ │ │ ├── Contents.json │ │ │ ├── appleMusicLogo.png │ │ │ ├── appleMusicLogo@2x.png │ │ │ └── appleMusicLogo@3x.png │ │ ├── background.colorset │ │ │ └── Contents.json │ │ ├── main.colorset │ │ │ └── Contents.json │ │ ├── mainLighter.colorset │ │ │ └── Contents.json │ │ ├── personCircle.imageset │ │ │ ├── Contents.json │ │ │ ├── personCircle.png │ │ │ ├── personCircle@2x.png │ │ │ └── personCircle@3x.png │ │ ├── spacingBackground.colorset │ │ │ └── Contents.json │ │ ├── tag.colorset │ │ │ └── Contents.json │ │ └── textFieldBackground.colorset │ │ │ └── Contents.json │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── Font │ │ ├── GmarketSansTTFBold.ttf │ │ ├── GmarketSansTTFLight.ttf │ │ ├── GmarketSansTTFMedium_2.ttf │ │ ├── Pretendard-Black.otf │ │ ├── Pretendard-Bold.otf │ │ ├── Pretendard-ExtraBold.otf │ │ ├── Pretendard-ExtraLight.otf │ │ ├── Pretendard-Light.otf │ │ ├── Pretendard-Medium.otf │ │ ├── Pretendard-Regular.otf │ │ ├── Pretendard-SemiBold.otf │ │ └── Pretendard-Thin.otf │ └── Info.plist └── Source │ ├── App │ ├── AppDelegate.swift │ ├── DIContainer.swift │ └── SceneDelegate.swift │ ├── Data │ ├── DataSource │ │ ├── LocalStorage │ │ │ ├── CoreData │ │ │ │ ├── CoreDataError.swift │ │ │ │ ├── CoreDataPlaylistStorage.swift │ │ │ │ └── PersistenceManager.swift │ │ │ ├── Protocol │ │ │ │ ├── AuthLocalStorage.swift │ │ │ │ └── PlaylistLocalStorage.swift │ │ │ └── UserDefaults │ │ │ │ ├── DefaultAuthLocalStorage.swift │ │ │ │ ├── UserDefaultsError.swift │ │ │ │ └── UserDefaultsKey.swift │ │ └── Network │ │ │ ├── FirebaseService │ │ │ ├── DefaultFirebaseAuthService.swift │ │ │ ├── Error │ │ │ │ ├── FirebaseAuthError.swift │ │ │ │ └── FirebaseStorageError.swift │ │ │ ├── FirebaseFollowRelationService.swift │ │ │ ├── FirebaseUserService.swift │ │ │ ├── Manager │ │ │ │ └── FirebaseStorageManager.swift │ │ │ └── Protocol │ │ │ │ ├── AuthService.swift │ │ │ │ ├── FollowRelationService.swift │ │ │ │ └── UserService.swift │ │ │ ├── FirestoreManager │ │ │ ├── FirestoreEntity.swift │ │ │ ├── FirestoreError.swift │ │ │ ├── FirestoreManager.swift │ │ │ ├── MolioPlaylist+Decodable.swift │ │ │ └── MolioPlaylist+FirestoreEntity.swift │ │ │ ├── ImageFetchService │ │ │ ├── DefaultImageFetchService.swift │ │ │ ├── ImageFecthError.swift │ │ │ └── ImageFetchService.swift │ │ │ ├── MusicKitService │ │ │ ├── DefaultMusicKitService.swift │ │ │ ├── MusicKitError.swift │ │ │ └── MusicKitService.swift │ │ │ ├── NetworkProvider │ │ │ ├── EndPoint.swift │ │ │ ├── HTTPMethod.swift │ │ │ ├── HTTPResponseStatusCode.swift │ │ │ ├── NetworkError.swift │ │ │ └── NetworkProvider.swift │ │ │ ├── PlaylistService │ │ │ ├── FirestorePlaylistService.swift │ │ │ └── PlaylistService.swift │ │ │ ├── SpotifyAPIService │ │ │ ├── DefaultSpotifyAPIService.swift │ │ │ ├── MockSpotifyAPIService.swift │ │ │ ├── SpotifyAPI.swift │ │ │ └── SpotifyAPIService.swift │ │ │ └── SpotifyTokenProvider │ │ │ ├── DefaultSpotifyTokenProvider.swift │ │ │ ├── MockSpotifyTokenProvider.swift │ │ │ ├── SpotifyAuthorizationAPI.swift │ │ │ ├── SpotifyTokenProvider.swift │ │ │ └── SpotifyTokenProviderError.swift │ ├── Mapper │ │ ├── MolioUserMapper.swift │ │ └── SongMapper.swift │ ├── Model │ │ ├── DTO │ │ │ ├── ExternalIDsDTO.swift │ │ │ ├── FollowRelationDTO.swift │ │ │ ├── MolioPlaylistDTO.swift │ │ │ ├── MolioUserDTO.swift │ │ │ ├── RecommendationsResponseDTO.swift │ │ │ ├── SpotifyAccessTokenResponseDTO.swift │ │ │ ├── SpotifyAvailableGenreSeedsDTO.swift │ │ │ └── TrackDTO.swift │ │ └── DataModel │ │ │ ├── MolioModel.xcdatamodeld │ │ │ └── MolioModel.xcdatamodel │ │ │ │ └── contents │ │ │ ├── Playlist+CoreDataClass.swift │ │ │ ├── Playlist+CoreDataProperties.swift │ │ │ └── PlaylistModel.xcdatamodeld │ │ │ └── Playlist.xcdatamodel │ │ │ └── contents │ └── Repository │ │ ├── DefaultAuthStateRepository.swift │ │ ├── DefaultCurrentPlaylistRepository.swift │ │ ├── DefaultImageRepository.swift │ │ ├── DefaultPlaylistRepository.swift │ │ └── DefaultRecommendedMusicRepository.swift │ ├── DesignSystem │ └── PretendardFontName.swift │ ├── Domain │ ├── Entity │ │ ├── Auth │ │ │ ├── AppleAuthInfo.swift │ │ │ ├── AuthMode.swift │ │ │ └── AuthSelection.swift │ │ ├── MolioFollowRelation.swift │ │ ├── MolioFollower.swift │ │ ├── MolioMusic+sample.swift │ │ ├── MolioMusic.swift │ │ ├── MolioPlaylist.swift │ │ ├── MolioUser.swift │ │ ├── MusicDeck │ │ │ └── RandomMusicDeck.swift │ │ ├── MusicGenre.swift │ │ ├── RGBAColor.swift │ │ └── RandomMusicDeck │ │ │ ├── DefaultRandomMusicDeck.swift │ │ │ └── RandomMusicDeck.swift │ ├── RepositoryInterface │ │ ├── AuthStateRepository.swift │ │ ├── CurrentPlaylistRepository.swift │ │ ├── ImageRepository.swift │ │ ├── PlaylistRepository.swift │ │ └── RecommendedMusicRepository.swift │ └── UseCase │ │ ├── AppleMusicUseCase │ │ ├── AppleMusicUseCase.swift │ │ └── DefaultAppleMusicUseCase.swift │ │ ├── CommunityUseCase │ │ ├── CommunityUseCase.swift │ │ └── DefaultCommunityUseCase.swift │ │ ├── CurrentUserIdUseCase │ │ ├── CurrentUserIdUseCase.swift │ │ └── DefaultCurrentUserIdUseCase.swift │ │ ├── FetchAvailableGenresUseCase │ │ ├── Implementation │ │ │ ├── DefaultFetchAvailableGenresUseCase.swift │ │ │ └── MockDefaultFetchAvailableGenresUseCase.swift │ │ └── Protocol │ │ │ └── FetchAvailableGenresUseCase.swift │ │ ├── FetchImageUsecase │ │ ├── Implementation │ │ │ └── DefaultFetchImageUseCase.swift │ │ └── Protocol │ │ │ └── FetchImageUseCase.swift │ │ ├── FetchRecommendedMusicUseCase │ │ ├── DefaultFetchRecommendedMusicUseCase.swift │ │ └── FetchRecommendedMusicUseCase.swift │ │ ├── FollowRelationUseCase │ │ ├── DefaultFollowRelationUseCase.swift │ │ └── FollowRelationUseCase.swift │ │ ├── ManageAuthenticationUseCase │ │ ├── DefaultManageAuthenticationUseCase.swift │ │ └── ManageAuthenticationUseCase.swift │ │ ├── ManageMyPlaylistUseCase │ │ ├── DefaultManageMyPlaylistUseCase.swift │ │ └── ManageMyPlaylistUseCase.swift │ │ ├── RealPlaylistUseCase │ │ ├── DefaultFetchPlaylistUseCase.swift │ │ ├── FetchPlaylistUseCase.swift │ │ └── FetchPlaylistUseCaseError.swift │ │ └── UserUseCase │ │ ├── DefaultUserUseCase.swift │ │ ├── ProfileImageUpdateType.swift │ │ └── UserUseCase.swift │ ├── Presentation │ ├── AudioPlayerControl │ │ ├── AudioPlayerControlView.swift │ │ └── AudioPlayerControlViewModel.swift │ ├── Common │ │ ├── Base │ │ │ └── InputOutputViewModel.swift │ │ └── Component │ │ │ ├── BasicButton.swift │ │ │ ├── CircleMenuButton.swift │ │ │ └── DefaultProfile.swift │ ├── Community │ │ ├── FollowRelation │ │ │ ├── FollowRelationViewController.swift │ │ │ ├── Model │ │ │ │ └── FollowRelationType.swift │ │ │ ├── View │ │ │ │ ├── FollowRelationButton.swift │ │ │ │ ├── FollowRelationListView.swift │ │ │ │ └── FollowRelationViewController.swift │ │ │ └── ViewModel │ │ │ │ └── FollowRelationViewModel.swift │ │ ├── Notification │ │ │ ├── NotificationView.swift │ │ │ └── NotificationViewModel.swift │ │ └── UserProfile │ │ │ ├── Model │ │ │ └── ProfileType.swift │ │ │ ├── UserProfileView.swift │ │ │ ├── View │ │ │ ├── ProfileImageView.swift │ │ │ └── UserProfileView.swift │ │ │ ├── ViewController │ │ │ ├── CommunityViewController.swift │ │ │ ├── FriendProfileViewController.swift │ │ │ └── UserProfileViewController.swift │ │ │ └── ViewModel │ │ │ └── UserProfileViewModel.swift │ ├── ExportPlaylist │ │ ├── Model │ │ │ ├── ExportMusicItem.swift │ │ │ └── ExportPlatform.swift │ │ ├── View │ │ │ ├── ExportAppleMusicPlaylist │ │ │ │ ├── ExportAppleMusicPlaylistView.swift │ │ │ │ └── ExportAppleMusicPlaylistViewController.swift │ │ │ ├── ExportPlaylistImage │ │ │ │ ├── ExportPlaylistImageView.swift │ │ │ │ ├── ExportPlaylistImageViewController.swift │ │ │ │ ├── PlaylistImageMusicItem.swift │ │ │ │ └── PlaylistImagePage.swift │ │ │ └── PlatformSelection │ │ │ │ ├── PlatformSelectionView.swift │ │ │ │ └── PlatformSelectionViewController.swift │ │ └── ViewModel │ │ │ └── ExportPlaylistImageViewModel.swift │ ├── FriendPlaylistDetailView │ │ ├── Delegate │ │ │ ├── ExportFriendsMusicToMyPlaylistDelegate.swift │ │ │ └── FriendPlaylistDetailHostingViewController+ExportFriendsMusicToMyPlaylistDelegate.swift │ │ ├── View │ │ │ ├── FriendPlaylistDetailHostingViewController.swift │ │ │ ├── FriendPlaylistDetailView.swift │ │ │ └── SelectPlaylistToExportFriendMusicView.swift │ │ └── ViewModel │ │ │ ├── FriendPlaylistDetailViewModel.swift │ │ │ └── SelectPlaylistToExportFriendMusicViewModel.swift │ ├── Login │ │ ├── View │ │ │ └── LoginViewController.swift │ │ └── ViewModel │ │ │ └── LoginViewModel.swift │ ├── ManagePlaylist │ │ ├── View │ │ │ ├── CreatePlaylist │ │ │ │ ├── CreatePlaylistView.swift │ │ │ │ └── CreatePlaylistViewController.swift │ │ │ └── SelectPlaylist │ │ │ │ ├── SelectPlaylistView.swift │ │ │ │ └── SelectPlaylistViewController.swift │ │ └── ViewModel │ │ │ └── ManagePlaylistViewModel.swift │ ├── MusicFilter │ │ ├── View │ │ │ ├── FilterTag.swift │ │ │ ├── MusicFilterView.swift │ │ │ ├── MusicFilterViewController.swift │ │ │ └── TagLayout.swift │ │ └── ViewModel │ │ │ └── MusicFilterViewModel.swift │ ├── MyInfo │ │ ├── View │ │ │ ├── ChangeProfileImageView.swift │ │ │ ├── DescriptionTextFieldView.swift │ │ │ ├── MyInfoView.swift │ │ │ ├── MyInfoViewController.swift │ │ │ └── NickNameTextFieldView.swift │ │ └── ViewModel │ │ │ └── MyInfoViewModel.swift │ ├── OnBoarding │ │ ├── OnBoardingAppleMusicAccessViewController.swift │ │ ├── OnBoardingCommunityViewController.swift │ │ ├── OnBoardingExportViewController.swift │ │ ├── OnBoardingFilterViewController.swift │ │ ├── OnBoardingFriendPlaylistViewController.swift │ │ ├── OnBoardingPage.swift │ │ ├── OnBoardingPlaylistViewController.swift │ │ ├── OnBoardingSwipeViewController.swift │ │ └── OnBoardingView.swift │ ├── PlaylistDetail │ │ ├── View │ │ │ ├── MusicCellView.swift │ │ │ ├── MusicListView.swift │ │ │ ├── PlaylistDetailView.swift │ │ │ └── PlaylistDetailViewController.swift │ │ └── ViewModel │ │ │ └── PlaylistDetailViewModel.swift │ ├── SearchUser │ │ ├── View │ │ │ ├── LoginRequiredView.swift │ │ │ ├── SearchBar.swift │ │ │ ├── SearchUserView.swift │ │ │ ├── SearchUserViewController.swift │ │ │ └── UserInfoCell.swift │ │ └── ViewModel │ │ │ └── SearchUserViewModel.swift │ ├── Setting │ │ ├── View │ │ │ ├── PrivacyPolicyView.swift │ │ │ ├── ProfileItemView.swift │ │ │ ├── SettingTextItemView.swift │ │ │ ├── SettingView.swift │ │ │ └── TermsAndConditionView.swift │ │ ├── ViewController │ │ │ └── SettingViewController.swift │ │ └── ViewModel │ │ │ └── SettingViewModel.swift │ ├── Splash │ │ ├── View │ │ │ └── SplashViewController.swift │ │ └── ViewModel │ │ │ └── SplashViewModel.swift │ ├── SwipeMusic │ │ ├── Model │ │ │ └── SwipeMusicTrackModel.swift │ │ ├── View │ │ │ ├── MusicCardView.swift │ │ │ ├── MusicTagView.swift │ │ │ └── SwipeMusicViewController.swift │ │ └── ViewModel │ │ │ ├── DefaultAudioPlayer.swift │ │ │ ├── SwipeMusicPlayer.swift │ │ │ └── SwipeMusicViewModel.swift │ └── Tabbar │ │ └── MolioTabBarController.swift │ └── Util │ ├── Extension │ ├── Foundation │ │ └── String+Extension.swift │ ├── SwiftUI │ │ ├── Font+Extension.swift │ │ ├── Image+Extension.swift │ │ ├── Text+Extension.swift │ │ └── View+Extension.swift │ └── UIKit │ │ ├── UIButton+Extension.swift │ │ ├── UIColor+Extension.swift │ │ ├── UILabel+Extension.swift │ │ ├── UIViewController │ │ ├── presentCustomSheet.swift │ │ └── showAlert.swift │ │ └── addGradientBackground.swift │ ├── Mapper │ ├── CGColorMapper.swift │ └── MolioPlaylistMapper.swift │ ├── Protocol │ ├── AudioPlayer.swift │ └── BidirectionalMapper.swift │ └── UserPlaylistSyncManager.swift ├── MolioTests ├── CoreDataPlaylistStorageTests.swift ├── DefaultCommnunityUseCaseTests.swift ├── DefaultCurrentUserIdUseCaseTests.swift ├── DefaultFetchPlaylistUseCaseTests.swift ├── DefaultFollowRelationUseCaseTests.swift ├── DefaultManageMyPlaylistUseCaseTests.swift ├── DefaultPlaylistRepositoryTests.swift ├── DefaultUserUseCaseTests.swift ├── FirebaseFollowRelationServiceTests.swift ├── FirebaseStorageManagerTests.swift ├── FirebaseUserServiceTests.swift ├── FirestoreManagerTests.swift ├── FirestorePlaylistServiceTests.swift ├── MusicDeckTests.swift ├── MusicFilterViewModelTests.swift ├── NetworkProviderTests.swift ├── PublishCurrentPlaylistUseCaseTests.swift ├── SpotifyTokenProviderTests.swift └── Test Double │ ├── Dummy │ ├── RecommendationsResponseDTODummy.swift │ ├── SpotifyAccessTokenResponseDTODummy.swift │ └── SpotifyAvailableGenreSeedsDTODummy.swift │ └── Mock │ ├── DataSource │ ├── MockAuthService.swift │ ├── MockFollowRelationService.swift │ ├── MockNetworkProvider.swift │ ├── MockPlaylistService.swift │ ├── MockPlaylistStorage.swift │ └── MockURLProtocol.swift │ ├── Repository │ ├── MockCurrentPlaylistRepository.swift │ └── MockPlaylistRepository.swift │ └── UseCase │ ├── MockCurrentUserIdUseCase.swift │ └── MockManagePlaylistUseCase.swift ├── README.md └── scripts ├── run_build.sh └── run_tests.sh /.github/ISSUE_TEMPLATE/기본-이슈-생성-템플릿.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 기본 이슈 생성 템플릿 3 | about: 이 템플릿으로 이슈를 생성해주세요. 4 | title: 'feat: 이슈 제목' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 0. 컨벤션 11 | - `feat` - 일반적인 개발 관련 12 | - `setting` - 프로젝트 설정 관련 13 | - `refactor` - 기능 개선 관련 14 | - `bugfix` - 오류 해결 15 | - `etc` - 기타 16 | 17 | ## 1. 이슈 내용 18 | 19 | ## 2. TODO 20 | - [ ] task 21 | 22 | ## 3. 참고자료 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/CI_BUILD.yml: -------------------------------------------------------------------------------- 1 | # 저장소의 Action 탭에서 표시될 워크플로우 이름 2 | name: 👷‍♂️ CI - Build 🧱 3 | 4 | on: 5 | # 워크플로우를 깃허브에서 직접 실행할 수 있게 해주는 옵션 6 | workflow_dispatch: 7 | # develop 브랜치에서 push나 pull request 이벤트 발생 시 해당 workflow가 트리거됨 8 | push: 9 | branches: 10 | - main 11 | - develop 12 | - "S002/*" 13 | - "S003/*" 14 | - "S015/*" 15 | - "S023/*" 16 | pull_request: 17 | branches: 18 | - main 19 | - develop 20 | 21 | # 하나 이상의 Job으로 구성 가능 22 | jobs: 23 | # 빌드를 수행하는 Job의 이름 24 | build: 25 | # 깃허브 액션 UI에서 표시될 Job 이름 26 | name: Build 27 | # Job을 실행할 OS 환경 28 | runs-on: macos-latest 29 | 30 | # Job이 실행할 Step들 31 | steps: 32 | # uses를 사용하면 깃허브 액션에서 기본적으로 제공하는 Action을 사용할 수 있다. 33 | # 레포지토리 코드를 체크아웃하는 동작. 이 작업을 통해 워크플로우가 현재 코드베이스에 접근할 수 있게 된다. 34 | - name: Checkout 35 | uses: actions/checkout@v4 36 | 37 | # 해당 가상 머신 환경에서의 가용 Xcode 버전을 출력 38 | - name: List available Xcode versions 39 | run: ls /Applications | grep Xcode 40 | 41 | # 현재 Xcode 버전 출력 42 | - name: Show current version of Xcode 43 | run: xcodebuild -version 44 | 45 | # Swift 버전 출력 46 | - name: Check Swift version 47 | run: xcrun swift --version 48 | 49 | # 현재 작업 디렉토리 확인 50 | - name: Show current working directory 51 | run: pwd 52 | 53 | # 현재 작업 디렉토리 내 파일 확인 54 | - name: List files in current directory 55 | run: ls -al 56 | 57 | # 시크릿 가져오기 58 | - name: Bring Secret files 59 | uses: actions/checkout@v4 60 | with: 61 | repository: kybeen/molioSecrets # 시크릿 파일이 있는 외부 저장소 62 | path: Molio/Resource/Secret # 시크릿 파일을 가져올 위치 63 | token: ${{ secrets.SECRET_REPO_ACCESS_TOKEN }} # private 저장소의 access token 64 | 65 | # 리소스 파일 확인 66 | - name: Check Resource files 67 | run: | 68 | ls -al 69 | cd Molio 70 | ls -al 71 | cd Resource 72 | ls -al 73 | 74 | # 테스트 스크립트 실행 75 | - name: Run build 76 | run: | 77 | ./scripts/run_build.sh 78 | -------------------------------------------------------------------------------- /.github/workflows/CI_TEST.yml: -------------------------------------------------------------------------------- 1 | # 저장소의 Action 탭에서 표시될 워크플로우 이름 2 | name: 👨‍🔬 CI - Test 🧪 3 | 4 | on: 5 | # 워크플로우를 깃허브에서 직접 실행할 수 있게 해주는 옵션 6 | workflow_dispatch: 7 | # develop 브랜치에서 push나 pull request 이벤트 발생 시 해당 workflow가 트리거됨 8 | push: 9 | branches: 10 | - main 11 | - develop 12 | - "S002/*" 13 | - "S003/*" 14 | - "S015/*" 15 | - "S023/*" 16 | pull_request: 17 | branches: 18 | - main 19 | - develop 20 | 21 | # 하나 이상의 Job으로 구성 가능 22 | jobs: 23 | # 빌드를 수행하는 Job의 이름 24 | build: 25 | # 깃허브 액션 UI에서 표시될 Job 이름 26 | name: Test 27 | # Job을 실행할 OS 환경 28 | runs-on: macos-latest 29 | 30 | # Job이 실행할 Step들 31 | steps: 32 | - name: Install yeetd 33 | run: | 34 | wget https://github.com/biscuitehh/yeetd/releases/download/1.0/yeetd-normal.pkg 35 | sudo installer -pkg yeetd-normal.pkg -target / 36 | yeetd & 37 | 38 | # uses를 사용하면 깃허브 액션에서 기본적으로 제공하는 Action을 사용할 수 있다. 39 | # 레포지토리 코드를 체크아웃하는 동작. 이 작업을 통해 워크플로우가 현재 코드베이스에 접근할 수 있게 된다. 40 | - name: Checkout 41 | uses: actions/checkout@v4 42 | 43 | # 해당 가상 머신 환경에서의 가용 Xcode 버전을 출력 44 | - name: List available Xcode versions 45 | run: ls /Applications | grep Xcode 46 | 47 | # 현재 Xcode 버전 출력 48 | - name: Show current version of Xcode 49 | run: xcodebuild -version 50 | 51 | # Swift 버전 출력 52 | - name: Check Swift version 53 | run: xcrun swift --version 54 | 55 | # 현재 작업 디렉토리 확인 56 | - name: Show current working directory 57 | run: pwd 58 | 59 | # 현재 작업 디렉토리 내 파일 확인 60 | - name: List files in current directory 61 | run: ls -al 62 | 63 | # 시크릿 가져오기 64 | - name: Bring Secret files 65 | uses: actions/checkout@v4 66 | with: 67 | repository: kybeen/molioSecrets # 시크릿 파일이 있는 외부 저장소 68 | path: Molio/Resource/Secret # 시크릿 파일을 가져올 위치 69 | token: ${{ secrets.SECRET_REPO_ACCESS_TOKEN }} # private 저장소의 access token 70 | 71 | # 리소스 파일 확인 72 | - name: Check Resource files 73 | run: | 74 | ls -al 75 | cd Molio 76 | ls -al 77 | cd Resource 78 | ls -al 79 | 80 | - name: Resolve Swift Packages 81 | run: xcodebuild -resolvePackageDependencies 82 | 83 | # 테스트 스크립트 실행 84 | - name: Run tests 85 | run: | 86 | ./scripts/run_tests.sh 87 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # 기본 활성화 룰 중 제외할 것들 2 | disabled_rules: 3 | - trailing_whitespace 4 | - multiple_closures_with_trailing_closure 5 | - type_body_length 6 | - line_length 7 | 8 | # 선택적으로 적용할 룰 9 | opt_in_rules: 10 | - empty_count # .isEmpty 대신 .count == 0를 사용하면 경고 11 | included: # 린트 과정에 포함할 파일 경로. 이 항목이 존재하면 `--path`는 무시됨 12 | excluded: # 린트 과정에서 무시할 파일 경로. `included`보다 우선순위 높음 13 | - Molio/Source/App 14 | # DTO 모델 생성 시 추가하기 15 | # 테스트 타겟 생성 시 추가하기 16 | 17 | # 설정 가능한 룰은 이 설정 파일에서 커스터마이징 가능 18 | # 경고나 에러 중 하나를 발생시키는 룰은 위반 수준을 설정 가능 19 | force_cast: warning # 암시적으로 지정 20 | force_try: 21 | severity: warning # 명시적으로 지정 22 | 23 | # 경고 및 에러 둘 다 존재하는 룰의 경우 값을 하나만 지정하면 암시적으로 경고 수준에 설정됨 24 | # line_length: 25 | # warning: 120 26 | # ignores_comments: true 27 | 28 | # 함수 길이 100줄로 제한 29 | function_body_length: 30 | warning: 100 31 | 32 | large_tuple: 33 | warning: 3 34 | nesting: 35 | type_level: 36 | warning: 3 # 3단계까지 중첩 허용, 그 이상이면 경고 37 | error: 5 # 5단계 이상이면 에러 발생 38 | type_name: 39 | min_length: 2 # 최소 길이 설정 40 | max_length: 40 # 최대 길이 설정 41 | 42 | identifier_name: 43 | min_length: # 식별자 길이가 2 이상이어야 함 44 | error: 1 45 | excluded: # 규칙에서 제외할 식별자 46 | - id 47 | - ok 48 | - db -------------------------------------------------------------------------------- /Molio.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Molio.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Molio/Molio.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.applesignin 6 | 7 | Default 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Molio/Resource/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 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/AlbumCoverSample.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "sample.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 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/AlbumCoverSample.imageset/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/AlbumCoverSample.imageset/sample.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/AppIcon.appiconset/114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/AppIcon.appiconset/114.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/AppIcon.appiconset/120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/AppIcon.appiconset/120.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/AppIcon.appiconset/180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/AppIcon.appiconset/180.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/AppIcon.appiconset/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/AppIcon.appiconset/29.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/AppIcon.appiconset/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/AppIcon.appiconset/40.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/AppIcon.appiconset/57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/AppIcon.appiconset/57.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/AppIcon.appiconset/58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/AppIcon.appiconset/58.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/AppIcon.appiconset/60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/AppIcon.appiconset/60.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/AppIcon.appiconset/80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/AppIcon.appiconset/80.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/AppIcon.appiconset/87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/AppIcon.appiconset/87.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "40.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "60.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "29.png", 17 | "idiom" : "iphone", 18 | "scale" : "1x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "58.png", 23 | "idiom" : "iphone", 24 | "scale" : "2x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "87.png", 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "29x29" 32 | }, 33 | { 34 | "filename" : "80.png", 35 | "idiom" : "iphone", 36 | "scale" : "2x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "120.png", 41 | "idiom" : "iphone", 42 | "scale" : "3x", 43 | "size" : "40x40" 44 | }, 45 | { 46 | "filename" : "57.png", 47 | "idiom" : "iphone", 48 | "scale" : "1x", 49 | "size" : "57x57" 50 | }, 51 | { 52 | "filename" : "114.png", 53 | "idiom" : "iphone", 54 | "scale" : "2x", 55 | "size" : "57x57" 56 | }, 57 | { 58 | "filename" : "120.png", 59 | "idiom" : "iphone", 60 | "scale" : "2x", 61 | "size" : "60x60" 62 | }, 63 | { 64 | "filename" : "180.png", 65 | "idiom" : "iphone", 66 | "scale" : "3x", 67 | "size" : "60x60" 68 | }, 69 | { 70 | "filename" : "1024.png", 71 | "idiom" : "ios-marketing", 72 | "scale" : "1x", 73 | "size" : "1024x1024" 74 | } 75 | ], 76 | "info" : { 77 | "author" : "xcode", 78 | "version" : 1 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onBoardingThree.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "onboardingThree.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "onboardingThree@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "onboardingThree@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onBoardingThree.imageset/onboardingThree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onBoardingThree.imageset/onboardingThree.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onBoardingThree.imageset/onboardingThree@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onBoardingThree.imageset/onboardingThree@2x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onBoardingThree.imageset/onboardingThree@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onBoardingThree.imageset/onboardingThree@3x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onBoardingTwo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "onboardingTwo.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "onboardingTwo@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "onboardingTwo@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onBoardingTwo.imageset/onboardingTwo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onBoardingTwo.imageset/onboardingTwo.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onBoardingTwo.imageset/onboardingTwo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onBoardingTwo.imageset/onboardingTwo@2x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onBoardingTwo.imageset/onboardingTwo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onBoardingTwo.imageset/onboardingTwo@3x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingFive.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "onboardingFive.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "onboardingFive@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "onboardingFive@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingFive.imageset/onboardingFive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onboardingFive.imageset/onboardingFive.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingFive.imageset/onboardingFive@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onboardingFive.imageset/onboardingFive@2x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingFive.imageset/onboardingFive@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onboardingFive.imageset/onboardingFive@3x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingFour.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "onboardingFour.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "onboardingFour@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "onboardingFour@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingFour.imageset/onboardingFour.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onboardingFour.imageset/onboardingFour.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingFour.imageset/onboardingFour@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onboardingFour.imageset/onboardingFour@2x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingFour.imageset/onboardingFour@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onboardingFour.imageset/onboardingFour@3x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingOne.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "onboardingOne.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "onboardingOne@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "onboardingOne@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingOne.imageset/onboardingOne.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onboardingOne.imageset/onboardingOne.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingOne.imageset/onboardingOne@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onboardingOne.imageset/onboardingOne@2x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingOne.imageset/onboardingOne@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onboardingOne.imageset/onboardingOne@3x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingSix.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "onboardingSix.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "onboardingSix@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "onboardingSix@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingSix.imageset/onboardingSix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onboardingSix.imageset/onboardingSix.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingSix.imageset/onboardingSix@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onboardingSix.imageset/onboardingSix@2x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/Onboarding/onboardingSix.imageset/onboardingSix@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/Onboarding/onboardingSix.imageset/onboardingSix@3x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/appleMusicLogo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "appleMusicLogo.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "appleMusicLogo@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "appleMusicLogo@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/appleMusicLogo.imageset/appleMusicLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/appleMusicLogo.imageset/appleMusicLogo.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/appleMusicLogo.imageset/appleMusicLogo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/appleMusicLogo.imageset/appleMusicLogo@2x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/appleMusicLogo.imageset/appleMusicLogo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/appleMusicLogo.imageset/appleMusicLogo@3x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/background.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x32", 9 | "green" : "0x32", 10 | "red" : "0x32" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/main.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xBB", 9 | "green" : "0xF3", 10 | "red" : "0x37" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/mainLighter.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xD3", 9 | "green" : "0xFF", 10 | "red" : "0x6C" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/personCircle.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "personCircle.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "personCircle@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "personCircle@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/personCircle.imageset/personCircle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/personCircle.imageset/personCircle.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/personCircle.imageset/personCircle@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/personCircle.imageset/personCircle@2x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/personCircle.imageset/personCircle@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Assets.xcassets/personCircle.imageset/personCircle@3x.png -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/spacingBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x1E", 9 | "green" : "0x1E", 10 | "red" : "0x1E" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/tag.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x58", 9 | "green" : "0x5A", 10 | "red" : "0x4E" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Molio/Resource/Assets.xcassets/textFieldBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x4D", 9 | "green" : "0x4D", 10 | "red" : "0x4D" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Molio/Resource/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 | -------------------------------------------------------------------------------- /Molio/Resource/Font/GmarketSansTTFBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Font/GmarketSansTTFBold.ttf -------------------------------------------------------------------------------- /Molio/Resource/Font/GmarketSansTTFLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Font/GmarketSansTTFLight.ttf -------------------------------------------------------------------------------- /Molio/Resource/Font/GmarketSansTTFMedium_2.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Font/GmarketSansTTFMedium_2.ttf -------------------------------------------------------------------------------- /Molio/Resource/Font/Pretendard-Black.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Font/Pretendard-Black.otf -------------------------------------------------------------------------------- /Molio/Resource/Font/Pretendard-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Font/Pretendard-Bold.otf -------------------------------------------------------------------------------- /Molio/Resource/Font/Pretendard-ExtraBold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Font/Pretendard-ExtraBold.otf -------------------------------------------------------------------------------- /Molio/Resource/Font/Pretendard-ExtraLight.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Font/Pretendard-ExtraLight.otf -------------------------------------------------------------------------------- /Molio/Resource/Font/Pretendard-Light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Font/Pretendard-Light.otf -------------------------------------------------------------------------------- /Molio/Resource/Font/Pretendard-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Font/Pretendard-Medium.otf -------------------------------------------------------------------------------- /Molio/Resource/Font/Pretendard-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Font/Pretendard-Regular.otf -------------------------------------------------------------------------------- /Molio/Resource/Font/Pretendard-SemiBold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Font/Pretendard-SemiBold.otf -------------------------------------------------------------------------------- /Molio/Resource/Font/Pretendard-Thin.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS06-molio/50c04dcebec5656f898509bddfb594097262b59b/Molio/Resource/Font/Pretendard-Thin.otf -------------------------------------------------------------------------------- /Molio/Resource/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleLocalizations 6 | 7 | en 8 | ko 9 | 10 | SPOTIFY_CLIENT_ID 11 | $(SPOTIFY_CLIENT_ID) 12 | SPOTIFY_CLIENT_SECRET 13 | $(SPOTIFY_CLIENT_SECRET) 14 | UIAppFonts 15 | 16 | GmarketSansTTFBold.ttf 17 | GmarketSansTTFLight.ttf 18 | GmarketSansTTFMedium_2.ttf 19 | Pretendard-Black.otf 20 | Pretendard-Bold.otf 21 | Pretendard-ExtraBold.otf 22 | Pretendard-ExtraLight.otf 23 | Pretendard-Light.otf 24 | Pretendard-Medium.otf 25 | Pretendard-Regular.otf 26 | Pretendard-SemiBold.otf 27 | Pretendard-Thin 28 | 29 | UIApplicationSceneManifest 30 | 31 | UIApplicationSupportsMultipleScenes 32 | 33 | UISceneConfigurations 34 | 35 | UIWindowSceneSessionRoleApplication 36 | 37 | 38 | UISceneConfigurationName 39 | Default Configuration 40 | UISceneDelegateClassName 41 | $(PRODUCT_MODULE_NAME).SceneDelegate 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /Molio/Source/App/DIContainer.swift: -------------------------------------------------------------------------------- 1 | final class DIContainer { 2 | static let shared = DIContainer() 3 | private init() {} 4 | 5 | private var dependencies: [String: Any] = [:] 6 | 7 | /// 의존성 등록 8 | /// - Parameters 9 | /// - `type`: 의존성 객체의 타입 10 | /// - `dependency`: 의존성 객체 인스턴스 11 | func register(_ type: T.Type, dependency: Any) { 12 | let key = "\(type)" 13 | dependencies[key] = dependency 14 | } 15 | 16 | /// 파라미터로 타입을 직접 지정하여 의존성 불러오기 17 | func resolve(_ type: T.Type) -> T { 18 | let key = "\(type)" 19 | return dependencies[key] as! T 20 | } 21 | 22 | /// 파라미터 없이 의존성 불러오기 23 | /// - 할당받는 쪽에서 타입 지정 필요 24 | func resolve() -> T { 25 | let key = "\(T.self)" 26 | return dependencies[key] as! T 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/LocalStorage/CoreData/CoreDataError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum CoreDataError: LocalizedError { 4 | case invalidID 5 | case saveFailed 6 | case updateFailed 7 | case notFound 8 | case contextUnavailable 9 | case unknownError 10 | 11 | var errorDescription: String? { 12 | switch self { 13 | case .invalidID: 14 | return "The playlist name provided is invalid." 15 | case .saveFailed: 16 | return "Failed to save the playlist." 17 | case .updateFailed: 18 | return "Failed to update the playlist." 19 | case .notFound: 20 | return "The requested playlist could not be found." 21 | case .contextUnavailable: 22 | return "The Core Data context is unavailable." 23 | case .unknownError: 24 | return "An unknown error occurred." 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/LocalStorage/CoreData/PersistenceManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | 4 | final class PersistenceManager { 5 | static let shared = PersistenceManager() 6 | private let moliomModelName: String = "MolioModel" 7 | private let persistenceContainer: NSPersistentContainer 8 | 9 | private init() { 10 | persistenceContainer = NSPersistentContainer(name: moliomModelName) 11 | persistenceContainer.loadPersistentStores { _, error in 12 | guard let error = error else { return } 13 | fatalError("Core Data Stack failed : \(error)") 14 | } 15 | } 16 | 17 | var context: NSManagedObjectContext { 18 | return persistenceContainer.viewContext 19 | } 20 | 21 | func saveContext() throws { 22 | let context = persistenceContainer.viewContext 23 | if context.hasChanges { 24 | do { 25 | try context.save() 26 | } catch { 27 | throw CoreDataError.saveFailed 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/LocalStorage/Protocol/AuthLocalStorage.swift: -------------------------------------------------------------------------------- 1 | protocol AuthLocalStorage { 2 | var authMode: AuthMode { get set } 3 | var authSelection: AuthSelection { get set } 4 | } 5 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/LocalStorage/Protocol/PlaylistLocalStorage.swift: -------------------------------------------------------------------------------- 1 | protocol PlaylistLocalStorage { 2 | func create(_ entity: MolioPlaylist) async throws 3 | func read(by id: String) async throws -> MolioPlaylist? 4 | func readAll() async throws -> [MolioPlaylist] 5 | func update(_ entity: MolioPlaylist) async throws 6 | func delete(by id: String) async throws 7 | } 8 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/LocalStorage/UserDefaults/DefaultAuthLocalStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct DefaultAuthLocalStorage: AuthLocalStorage { 4 | private let userDefault = UserDefaults.standard 5 | private let authModeKey: String = "auth_mode" 6 | private let authSelectionKey: String = "auth_selection" 7 | 8 | var authMode: AuthMode { 9 | get { 10 | let rawValue = userDefault.string(forKey: authModeKey) ?? "" 11 | return AuthMode(rawValue: rawValue) ?? .guest 12 | } 13 | set { 14 | userDefault.set(newValue.rawValue, forKey: authModeKey) 15 | } 16 | } 17 | 18 | var authSelection: AuthSelection { 19 | get { 20 | let rawValue = userDefault.string(forKey: authSelectionKey) ?? "" 21 | return AuthSelection(rawValue: rawValue) ?? .unselected 22 | } 23 | set { 24 | userDefault.set(newValue.rawValue, forKey: authSelectionKey) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/LocalStorage/UserDefaults/UserDefaultsError.swift: -------------------------------------------------------------------------------- 1 | enum UserDefaultsError: Error { 2 | case notFound 3 | } 4 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/LocalStorage/UserDefaults/UserDefaultsKey.swift: -------------------------------------------------------------------------------- 1 | enum UserDefaultKey: String { 2 | case currentPlaylist, defaultPlaylist, isOnboarded 3 | } 4 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/FirebaseService/DefaultFirebaseAuthService.swift: -------------------------------------------------------------------------------- 1 | import FirebaseAuth 2 | 3 | struct DefaultFirebaseAuthService: AuthService { 4 | 5 | func getCurrentUser() throws -> User { 6 | guard let currentUser = Auth.auth().currentUser else { 7 | throw FirebaseAuthError.userNotFound 8 | } 9 | return currentUser 10 | } 11 | 12 | func getCurrentID() throws -> String { 13 | return try getCurrentUser().uid 14 | } 15 | 16 | func signInApple(info: AppleAuthInfo) async throws -> (uid: String, isNewUser: Bool) { 17 | let credential = OAuthProvider.appleCredential( 18 | withIDToken: info.idToken, 19 | rawNonce: info.nonce, 20 | fullName: info.fullName 21 | ) 22 | do { 23 | let authResult = try await Auth.auth().signIn(with: credential) 24 | return (authResult.user.uid, authResult.additionalUserInfo?.isNewUser ?? false) 25 | } catch { 26 | throw FirebaseAuthError.loginFailed 27 | } 28 | } 29 | 30 | func logout() throws { 31 | do { 32 | try Auth.auth().signOut() 33 | } catch { 34 | throw FirebaseAuthError.logoutFailed 35 | } 36 | } 37 | 38 | func reauthenticateApple(idToken: String, nonce: String) async throws { 39 | guard Auth.auth().currentUser != nil else { 40 | throw FirebaseAuthError.userNotFound 41 | } 42 | let credential = OAuthProvider.credential( 43 | providerID: .apple, 44 | idToken: idToken, 45 | rawNonce: nonce 46 | ) 47 | do { 48 | try await getCurrentUser().reauthenticate(with: credential) 49 | } catch { 50 | throw FirebaseAuthError.reauthenticateFailed 51 | } 52 | } 53 | 54 | func deleteAccount(authorizationCode: String) async throws { 55 | guard let currentUser = Auth.auth().currentUser else { 56 | throw FirebaseAuthError.userNotFound 57 | } 58 | do { 59 | try await Auth.auth().revokeToken(withAuthorizationCode: authorizationCode) 60 | try await currentUser.delete() 61 | } catch let error as NSError { 62 | if error.code == AuthErrorCode.requiresRecentLogin.rawValue { 63 | throw FirebaseAuthError.requiresReauthentication 64 | } else { 65 | throw FirebaseAuthError.deleteAccountFailed 66 | } 67 | } 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/FirebaseService/Error/FirebaseAuthError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum FirebaseAuthError: LocalizedError { 4 | case loginFailed 5 | case logoutFailed 6 | case requiresReauthentication 7 | case userNotFound 8 | case deleteAccountFailed 9 | case reauthenticateFailed 10 | 11 | var errorDescription: String? { 12 | switch self { 13 | case .loginFailed: 14 | return "로그인 실패" 15 | case .logoutFailed: 16 | return "로그인 아웃 실패" 17 | case .requiresReauthentication: 18 | return "재인증이 필요함" 19 | case .userNotFound: 20 | return "사용자를 찾을 수 없음" 21 | case .deleteAccountFailed: 22 | return "계정 탈퇴 실패" 23 | case .reauthenticateFailed: 24 | return "재인증 실패" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/FirebaseService/Error/FirebaseStorageError.swift: -------------------------------------------------------------------------------- 1 | enum FirebaseStorageError: Error { 2 | case invalidImageData 3 | case saveFailed 4 | case notFound 5 | } 6 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/FirebaseService/Manager/FirebaseStorageManager.swift: -------------------------------------------------------------------------------- 1 | import FirebaseStorage 2 | import Foundation 3 | 4 | final class FirebaseStorageManager { 5 | private let storage = Storage.storage() 6 | 7 | func uploadImage( 8 | imageData: Data, 9 | folder: FolderType, 10 | userID fileName: String 11 | ) async throws -> URL { 12 | 13 | guard !imageData.isEmpty else { 14 | throw FirebaseStorageError.invalidImageData 15 | } 16 | 17 | // 1. Storage 경로 생성 18 | let storagePath = "\(folder)/\(fileName).jpg" 19 | 20 | // 2. Storage에 업로드 21 | let storageRef = storage.reference().child(storagePath) 22 | _ = try await storageRef.putDataAsync(imageData, metadata: nil) 23 | 24 | return try await storageRef.downloadURL() 25 | } 26 | 27 | enum FolderType: String { 28 | case profileImage 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/FirebaseService/Protocol/AuthService.swift: -------------------------------------------------------------------------------- 1 | import FirebaseAuth 2 | 3 | protocol AuthService { 4 | func getCurrentUser() async throws -> User 5 | func getCurrentID() throws -> String 6 | func signInApple(info: AppleAuthInfo) async throws -> (uid: String, isNewUser: Bool) 7 | func logout() throws 8 | func reauthenticateApple(idToken: String, nonce: String) async throws 9 | func deleteAccount(authorizationCode: String) async throws 10 | } 11 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/FirebaseService/Protocol/FollowRelationService.swift: -------------------------------------------------------------------------------- 1 | protocol FollowRelationService { 2 | func createFollowRelation(from followerID: String, to followingID: String) async throws 3 | func readFollowRelation(followingID: String?, followerID: String?, state: Bool?) async throws -> [FollowRelationDTO] 4 | func updateFollowRelation(relationID: String, state: Bool) async throws 5 | func deleteFollowRelation(relationID: String) async throws 6 | } 7 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/FirebaseService/Protocol/UserService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol UserService { 4 | func createUser(_ user: MolioUserDTO) async throws 5 | func readUser(userID: String) async throws -> MolioUserDTO 6 | func updateUser(_ user: MolioUserDTO) async throws 7 | func deleteUser( userID: String) async throws 8 | func readAllUsers() async throws -> [MolioUserDTO] 9 | func uploadUserImage(userID: String, data: Data) async throws -> URL 10 | } 11 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/FirestoreManager/FirestoreEntity.swift: -------------------------------------------------------------------------------- 1 | protocol FirestoreEntity: Identifiable { 2 | var toDictionary: [String: Any]? { get } 3 | var idString: String { get } 4 | 5 | static var collectionName: String { get } 6 | static var firebaseIDFieldName: String { get } 7 | } 8 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/FirestoreManager/FirestoreError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum FirestoreError: LocalizedError { 4 | case failedToConvertToDictionary 5 | case documentFetchError 6 | case documentNotFound 7 | 8 | var errorDescription: String? { 9 | switch self { 10 | case .failedToConvertToDictionary: 11 | return "딕셔너리로 변환 실패" 12 | case .documentFetchError: 13 | return "문서를 가져오는 중 오류 발생" 14 | case .documentNotFound: 15 | return "문서를 찾을 수 없음" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/FirestoreManager/MolioPlaylist+Decodable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension MolioPlaylist: Decodable { 4 | init(from decoder: Decoder) throws { 5 | let container = try decoder.container(keyedBy: CodingKeys.self) 6 | 7 | self.init( 8 | id: try container.decode(UUID.self, forKey: .playlistID), 9 | name: try container.decode(String.self, forKey: .playlistName), 10 | createdAt: try container.decode(Date.self, forKey: .createdAt), 11 | musicISRCs: try container.decode([String].self, forKey: .musicISRCs), 12 | filter: try container.decode([String].self, forKey: .filters), 13 | like: try container.decode([String].self, forKey: .like) 14 | ) 15 | } 16 | 17 | enum CodingKeys: String, CodingKey { 18 | case playlistID 19 | case playlistName 20 | case musicISRCs 21 | case filters 22 | case like 23 | case createdAt 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/FirestoreManager/MolioPlaylist+FirestoreEntity.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension MolioPlaylist: FirestoreEntity { 4 | var idString: String { 5 | id.uuidString 6 | } 7 | 8 | var toDictionary: [String: Any]? { 9 | [ 10 | "createdAt": createdAt, 11 | "filters": filter, 12 | "like": like, 13 | "musicISRCs": musicISRCs, 14 | "playlistID": id.uuidString, 15 | "playlistName": name 16 | ] 17 | } 18 | 19 | static var collectionName: String { 20 | "playlists" 21 | } 22 | 23 | static var firebaseIDFieldName: String { 24 | "playlistID" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/ImageFetchService/DefaultImageFetchService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class DefaultImageFetchService: ImageFetchService { 4 | private let session: URLSession 5 | 6 | init(session: URLSession = .shared) { 7 | self.session = session 8 | } 9 | 10 | func fetchImage(from url: URL) async throws -> Data { 11 | do { 12 | let (data, _) = try await session.data(from: url) 13 | guard !data.isEmpty else { 14 | throw ImageFecthError.noData 15 | } 16 | return data 17 | } catch URLError.badURL { 18 | throw ImageFecthError.invalidURL 19 | } catch { 20 | throw ImageFecthError.networkError 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/ImageFetchService/ImageFecthError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum ImageFecthError: LocalizedError { 4 | case invalidURL 5 | case networkError 6 | case noData 7 | 8 | var errorDescription: String? { 9 | switch self { 10 | case .invalidURL: 11 | return "유효하지 않은 URL" 12 | case .networkError: 13 | return "이미지를 불러오는 중 네트워크 오류가 발생" 14 | case .noData: 15 | return "이미지 데이터 없음" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/ImageFetchService/ImageFetchService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol ImageFetchService { 4 | func fetchImage(from url: URL) async throws -> Data 5 | } 6 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/MusicKitService/MusicKitError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum MusicKitError: LocalizedError { 4 | case deniedPermission 5 | case restricted 6 | case failedSubscriptionCheck 7 | case didNotSubscribe 8 | case failedToCreatePlaylist(name: String) 9 | 10 | var errorDescription: String? { 11 | switch self { 12 | case .deniedPermission: "Apple Music 접근 권한을 승인하지 않았습니다." 13 | case .restricted: "현재 디바이스에서 Apple Music 서비스를 이용할 수 없습니다." 14 | case .failedSubscriptionCheck: "Apple Music 구독 여부 확인에 실패했습니다." 15 | case .didNotSubscribe: "Apple Music을 구독하고 있지 않습니다." 16 | case .failedToCreatePlaylist(let name): "[\(name)] 플레이리스트를 생성하는데 실패했습니다." 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/MusicKitService/MusicKitService.swift: -------------------------------------------------------------------------------- 1 | protocol MusicKitService { 2 | func checkAuthorizationStatus() async throws 3 | func checkSubscriptionStatus() async throws -> Bool 4 | 5 | func fetchGenres() async throws -> [MusicGenre] 6 | func fetchRecommendedMusics(by genres: [MusicGenre]) async throws -> [MolioMusic] 7 | 8 | func getMusic(with isrcs: [String]) async throws -> [MolioMusic] 9 | 10 | func exportAppleMusicPlaylist(name: String, isrcs: [String]) async throws -> String? 11 | } 12 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/NetworkProvider/EndPoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol EndPoint { 4 | /// API 베이스 URL String 5 | var base: String { get } 6 | 7 | /// 엔드포인트 `URL`을 만들기 위해 `baseURL`에 추가할 경로 8 | var path: String { get } 9 | 10 | /// 요청에 사용할 HTTP Method 11 | var httpMethod: HTTPMethod { get } 12 | 13 | /// 요청에 사용할 헤더 14 | var headers: [String: String?]? { get } 15 | 16 | /// 요청에 사용할 바디 데이터 17 | var body: Data? { get } 18 | 19 | /// 요청 쿼리 파라미터 20 | var params: [String: String]? { get } 21 | 22 | var description: String { get } 23 | } 24 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/NetworkProvider/HTTPMethod.swift: -------------------------------------------------------------------------------- 1 | /// HTTP Request Method 2 | enum HTTPMethod: String { 3 | case get 4 | case post 5 | 6 | var value: String { 7 | rawValue.uppercased() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/NetworkProvider/HTTPResponseStatusCode.swift: -------------------------------------------------------------------------------- 1 | /// Spotify API 응답 코드 기반 2 | /// - https://developer.spotify.com/documentation/web-api/concepts/api-calls 3 | enum HTTPResponseStatusCode: Int { 4 | // 2xx: 성공 5 | case ok = 200 6 | case created = 201 7 | case accepted = 202 8 | case noContent = 204 9 | 10 | // 3xx: 리다이렉션 11 | case notModified = 304 12 | 13 | // 4xx: 클라이언트 오류 14 | case badRequest = 400 15 | case unAuthorized = 401 16 | case forbidden = 403 17 | case notFound = 404 18 | case tooManyReqiests = 429 19 | 20 | // 5xx: 서버 오류 21 | case internalServerError = 500 22 | case badGateway = 502 23 | case serviceUnavailable = 503 24 | 25 | var description: String { 26 | let code = self.rawValue 27 | switch self { 28 | case .ok: return "(\(code)) 요청 성공" 29 | case .created: return "(\(code)) 요청 성공 + 새 리소스 생성" 30 | case .accepted: return "(\(code)) 요청이 수락되었으나 아직 처리되지 않음" 31 | case .noContent: return "(\(code)) 요청은 성공했으나 message body가 존재하지 않음" 32 | case .notModified: return "(\(code)) 리소스 변경 없음 (캐시된 응답을 사용하세요)" 33 | case .badRequest: return "(\(code)) 잘못된 요청" 34 | case .unAuthorized: return "(\(code)) 인증 필요" 35 | case .forbidden: return "(\(code)) 요청이 거절됨 (권한 없음)" 36 | case .notFound: return "(\(code)) 리소스를 찾을 수 없음" 37 | case .tooManyReqiests: return "(\(code)) 요청이 너무 많음" 38 | case .internalServerError: return "(\(code)) 서버 내부 오류" 39 | case .badGateway: return "(\(code)) 잘못된 게이트웨이" 40 | case .serviceUnavailable: return "(\(code)) 서비스 이용 불가" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/NetworkProvider/NetworkError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum NetworkError: LocalizedError { 4 | case invalidURL 5 | case requestFail(code: HTTPResponseStatusCode?) 6 | case responseNotHTTP 7 | case urlDownloadsError 8 | 9 | var errorDescription: String? { 10 | switch self { 11 | case .invalidURL: 12 | return "잘못된 URL" 13 | case let .requestFail(code): 14 | let base = "요청 실패 " 15 | if let code = code { 16 | return base + "\(code.description)" 17 | } else { 18 | return base 19 | } 20 | case .responseNotHTTP: 21 | return "응답이 HTTP가 아님" 22 | case .urlDownloadsError: 23 | return "URL 콘텐츠 다운로드 에러" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/NetworkProvider/NetworkProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - Protocol 4 | 5 | protocol NetworkProvider { 6 | /// 요청을 수행하고 응답 데이터를 반환하는 메서드 7 | /// - Parameters: 요청 메시지 8 | /// - Throws 9 | /// - 요청이 정상적이지 않은 경우 발생 10 | /// - 응답이 정상적이지 않은 경우 발생 11 | /// - Returns: 응답 Data 12 | @discardableResult 13 | func request(_ endPoint: any EndPoint) async throws -> T 14 | } 15 | 16 | private extension NetworkProvider { 17 | func makeURLRequest(of endPoint: any EndPoint) throws -> URLRequest { 18 | guard let url = makeURL(of: endPoint) else { throw NetworkError.invalidURL } 19 | var urlRequest = URLRequest(url: url) 20 | urlRequest.httpMethod = endPoint.httpMethod.rawValue 21 | endPoint.headers?.forEach { 22 | urlRequest.setValue($0.value, forHTTPHeaderField: $0.key) 23 | } 24 | urlRequest.httpBody = endPoint.body 25 | 26 | return urlRequest 27 | } 28 | 29 | func makeURL(of endPoint: any EndPoint) -> URL? { 30 | var components = URLComponents(string: endPoint.base) 31 | components?.path = endPoint.path 32 | components?.queryItems = endPoint.params?.map { URLQueryItem(name: $0.key, value: $0.value) } 33 | return components?.url 34 | } 35 | } 36 | 37 | // MARK: - Implementation 38 | 39 | final class DefaultNetworkProvider: NetworkProvider { 40 | private let session: URLSession 41 | private let decoder = JSONDecoder() 42 | 43 | init(session: URLSession = .shared) { 44 | self.session = session 45 | } 46 | 47 | @discardableResult 48 | func request(_ endPoint: any EndPoint) async throws -> T { 49 | let urlRequest = try makeURLRequest(of: endPoint) 50 | let (data, response) = try await session.data(for: urlRequest) 51 | guard let httpResponse = response as? HTTPURLResponse else { 52 | throw NetworkError.responseNotHTTP 53 | } 54 | guard (200...299).contains(httpResponse.statusCode) else { 55 | let statusCode = HTTPResponseStatusCode(rawValue: httpResponse.statusCode) 56 | throw NetworkError.requestFail(code: statusCode) 57 | } 58 | let dto = try decoder.decode(T.self, from: data) 59 | return dto 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/PlaylistService/FirestorePlaylistService.swift: -------------------------------------------------------------------------------- 1 | import FirebaseCore 2 | import FirebaseFirestore 3 | 4 | final class FirestorePlaylistService: PlaylistService { 5 | private let db: Firestore 6 | private let collectionName = "playlists" 7 | 8 | init() { 9 | if FirebaseApp.app() == nil { 10 | FirebaseApp.configure() 11 | } 12 | self.db = Firestore.firestore() 13 | } 14 | 15 | func createPlaylist(playlist: MolioPlaylistDTO) async throws { 16 | let docRef = getDocumentReference(documentName: playlist.id) 17 | 18 | return try await withCheckedThrowingContinuation { continuation in 19 | do { 20 | try docRef.setData(from: playlist) { error in 21 | if let error { 22 | continuation.resume(throwing: error) 23 | } else { 24 | continuation.resume(returning: ()) 25 | } 26 | } 27 | } catch { 28 | continuation.resume(throwing: error) 29 | } 30 | } 31 | } 32 | 33 | func readPlaylist(playlistID: UUID) async throws -> MolioPlaylistDTO { 34 | let docRef = getDocumentReference(documentName: playlistID.uuidString) 35 | 36 | return try await docRef.getDocument(as: MolioPlaylistDTO.self) 37 | } 38 | 39 | func readAllPlaylist(userID: String) async throws -> [MolioPlaylistDTO] { 40 | 41 | try await db.collection(collectionName) 42 | .whereField("authorID", isEqualTo: userID) 43 | .getDocuments() 44 | .documents 45 | .compactMap { try? $0.data(as: MolioPlaylistDTO.self) } 46 | } 47 | 48 | func updatePlaylist(newPlaylist: MolioPlaylistDTO) async throws { 49 | let docRef = getDocumentReference(documentName: newPlaylist.id) 50 | 51 | guard let dictionary = newPlaylist.toDictionary() else { 52 | throw FirestoreError.failedToConvertToDictionary 53 | } 54 | 55 | return try await docRef.updateData(dictionary) 56 | } 57 | 58 | func deletePlaylist(playlistID: UUID) async throws { 59 | let docRef = db.collection(collectionName).document(playlistID.uuidString) 60 | 61 | return try await docRef.delete() 62 | } 63 | 64 | private func getDocumentReference(documentName: String) -> DocumentReference { 65 | return db.collection(collectionName).document(documentName) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/PlaylistService/PlaylistService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol PlaylistService { 4 | func createPlaylist(playlist: MolioPlaylistDTO) async throws 5 | func readPlaylist(playlistID: UUID) async throws -> MolioPlaylistDTO 6 | func readAllPlaylist(userID: String) async throws -> [MolioPlaylistDTO] 7 | func updatePlaylist(newPlaylist: MolioPlaylistDTO) async throws 8 | func deletePlaylist(playlistID: UUID) async throws 9 | } 10 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/SpotifyAPIService/DefaultSpotifyAPIService.swift: -------------------------------------------------------------------------------- 1 | struct DefaultSpotifyAPIService: SpotifyAPIService { 2 | private let networkProvider: NetworkProvider 3 | private let tokenProvider: SpotifyTokenProvider 4 | 5 | init( 6 | networkProvider: NetworkProvider = DIContainer.shared.resolve(), 7 | tokenProvider: SpotifyTokenProvider = DIContainer.shared.resolve() 8 | ) { 9 | self.networkProvider = networkProvider 10 | self.tokenProvider = tokenProvider 11 | } 12 | 13 | func fetchRecommendedMusicISRCs(with filter: [MusicGenre]) async throws -> [String] { 14 | let accessToken = try await tokenProvider.getAccessToken() 15 | let genresParam = filter 16 | let endPoint = SpotifyAPI.getRecommendations(genres: genresParam, accessToken: accessToken) 17 | let dto: RecommendationsResponseDTO = try await networkProvider.request(endPoint) 18 | return dto.tracks.map(\.externalIDs.isrc) 19 | } 20 | 21 | func fetchAvailableGenreSeeds() async throws -> SpotifyAvailableGenreSeedsDTO { 22 | let accessToken = try await tokenProvider.getAccessToken() 23 | let endPoint = SpotifyAPI.getAvailableGenreSeeds(accessToken: accessToken) 24 | let dto: SpotifyAvailableGenreSeedsDTO = try await networkProvider.request(endPoint) 25 | return dto 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/SpotifyAPIService/SpotifyAPI.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum SpotifyAPI { 4 | case getAvailableGenreSeeds(accessToken: String) 5 | case getRecommendations(genres: [String], accessToken: String) 6 | 7 | var description: String { 8 | switch self { 9 | case .getAvailableGenreSeeds: "유효한 장르 시드 검색 불러오기 엔드포인트" 10 | case .getRecommendations(let genres, _): "[\(genres.joined(separator: ","))] 장르에 대한 추천 음악 불러오기 엔드포인트" 11 | } 12 | } 13 | } 14 | 15 | extension SpotifyAPI: EndPoint { 16 | var base: String { 17 | "https://api.spotify.com" 18 | } 19 | 20 | var path: String { 21 | switch self { 22 | case .getAvailableGenreSeeds: "/v1/recommendations/available-genre-seeds" 23 | case .getRecommendations: "/v1/recommendations" 24 | } 25 | } 26 | 27 | var httpMethod: HTTPMethod { 28 | switch self { 29 | case .getAvailableGenreSeeds: .get 30 | case .getRecommendations: .get 31 | } 32 | } 33 | 34 | var headers: [String: String?]? { 35 | switch self { 36 | case .getAvailableGenreSeeds(let accessToken): 37 | return makeAuthorizationHeader(with: accessToken) 38 | case .getRecommendations(_, let accessToken): 39 | return makeAuthorizationHeader(with: accessToken) 40 | } 41 | } 42 | 43 | var body: Data? { 44 | switch self { 45 | case .getAvailableGenreSeeds: nil 46 | case .getRecommendations: nil 47 | } 48 | } 49 | 50 | var params: [String: String]? { 51 | switch self { 52 | case .getAvailableGenreSeeds: 53 | return nil 54 | case .getRecommendations(let genres, _): 55 | return [ 56 | "limit": "100", 57 | "market": "KR", 58 | "seed_genres": genres.joined(separator: ",") 59 | ] 60 | } 61 | } 62 | } 63 | 64 | private extension SpotifyAPI { 65 | func makeAuthorizationHeader(with accessToken: String) -> [String: String] { 66 | return ["Authorization": "Bearer \(accessToken)"] 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/SpotifyAPIService/SpotifyAPIService.swift: -------------------------------------------------------------------------------- 1 | protocol SpotifyAPIService { 2 | func fetchRecommendedMusicISRCs(with filter: [MusicGenre]) async throws -> [String] 3 | func fetchAvailableGenreSeeds() async throws -> SpotifyAvailableGenreSeedsDTO 4 | } 5 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/SpotifyTokenProvider/DefaultSpotifyTokenProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class DefaultSpotifyTokenProvider: SpotifyTokenProvider { 4 | private let networkProvider: NetworkProvider 5 | private var accessToken: String? 6 | private var expireTime: Date? 7 | 8 | private var isTokenExpiringSoon: Bool { 9 | guard let expireTime else { return true } 10 | let now = Date.now 11 | let remainingSecond = Int(expireTime.timeIntervalSince(now)) 12 | return remainingSecond < 300 13 | } 14 | 15 | init( 16 | networkProvider: NetworkProvider = DIContainer.shared.resolve() 17 | ) { 18 | self.networkProvider = networkProvider 19 | } 20 | 21 | /// access token 불러오기 22 | /// - accessToken이 없거나, 토큰 만료 시간까지 5분보다 적게 남았을 경우(or 이미 만료됐을 경우) => 토큰 새로 발급 23 | /// - 그렇지 않을 경우 => 기존 토큰 그대로 반환 24 | func getAccessToken() async throws -> String { 25 | guard let accessToken = accessToken, 26 | !isTokenExpiringSoon else { 27 | return try await requestNewAccessToken() 28 | } 29 | return accessToken 30 | } 31 | 32 | /// 새로운 access token 요청 33 | private func requestNewAccessToken() async throws -> String { 34 | do { 35 | let endPoint = SpotifyAuthorizationAPI.createAccessToken 36 | let responseDTO: SpotifyAccessTokenResponseDTO = try await networkProvider.request(endPoint) 37 | let newAccessToken = responseDTO.accessToken 38 | updateAccessToken(to: newAccessToken, expirationSecond: responseDTO.expiresIn) 39 | return newAccessToken 40 | } catch { 41 | throw SpotifyTokenProviderError.failedToCreateToken 42 | } 43 | } 44 | 45 | /// 새로운 access token 정보로 업데이트 46 | /// - `accessToken` : 새로 생성한 토큰으로 변경 47 | /// - `expireTime` : 새로 생성한 토큰의 만료예정시간으로 변경 48 | private func updateAccessToken(to newToken: String, expirationSecond: Int) { 49 | let expireTime = Date.now.addingTimeInterval(Double(expirationSecond)) 50 | self.accessToken = newToken 51 | self.expireTime = expireTime 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/SpotifyTokenProvider/MockSpotifyTokenProvider.swift: -------------------------------------------------------------------------------- 1 | struct MockSpotifyTokenProvider: SpotifyTokenProvider { 2 | var accessTokenToReturn: String = "" 3 | var isValid: Bool = true 4 | 5 | func getAccessToken() async throws -> String { 6 | if isValid { 7 | return accessTokenToReturn 8 | } else { 9 | throw NetworkError.requestFail(code: .badRequest) 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/SpotifyTokenProvider/SpotifyAuthorizationAPI.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum SpotifyAuthorizationAPI { 4 | case createAccessToken 5 | 6 | var description: String { 7 | switch self { 8 | case .createAccessToken: "access token 요청 엔드포인트" 9 | } 10 | } 11 | } 12 | 13 | extension SpotifyAuthorizationAPI: EndPoint { 14 | var base: String { 15 | "https://accounts.spotify.com" 16 | } 17 | 18 | var path: String { 19 | switch self { 20 | case .createAccessToken: "/api/token" 21 | } 22 | } 23 | 24 | var httpMethod: HTTPMethod { 25 | switch self { 26 | case .createAccessToken: .post 27 | } 28 | } 29 | 30 | var headers: [String: String?]? { 31 | return [ 32 | Header.Authorization.field: Header.Authorization.value, 33 | Header.ContentType.field: Header.ContentType.value 34 | ] 35 | } 36 | 37 | var body: Data? { 38 | switch self { 39 | case .createAccessToken: nil 40 | } 41 | } 42 | 43 | var params: [String: String]? { 44 | switch self { 45 | case .createAccessToken: 46 | return ["grant_type": "client_credentials"] 47 | } 48 | } 49 | } 50 | 51 | private extension SpotifyAuthorizationAPI { 52 | enum Header { 53 | enum Authorization { 54 | static let field = "Authorization" 55 | static var value: String? { 56 | guard let clientID = Bundle.main.object(forInfoDictionaryKey: "SPOTIFY_CLIENT_ID") as? String, 57 | let clientSecret = Bundle.main.object(forInfoDictionaryKey: "SPOTIFY_CLIENT_SECRET") as? String, 58 | let base64ClientKey = "\(clientID):\(clientSecret)".toBase64 else { 59 | return nil 60 | } 61 | return "Basic \(base64ClientKey)" 62 | } 63 | } 64 | 65 | enum ContentType { 66 | static let field = "Content-Type" 67 | static let value = "application/x-www-form-urlencoded" 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/SpotifyTokenProvider/SpotifyTokenProvider.swift: -------------------------------------------------------------------------------- 1 | protocol SpotifyTokenProvider { 2 | func getAccessToken() async throws -> String 3 | } 4 | -------------------------------------------------------------------------------- /Molio/Source/Data/DataSource/Network/SpotifyTokenProvider/SpotifyTokenProviderError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum SpotifyTokenProviderError: LocalizedError { 4 | case failedToCreateToken 5 | 6 | var errorDescription: String? { 7 | switch self { 8 | case .failedToCreateToken: "토큰을 새로 불러오지 못했습니다." 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Molio/Source/Data/Mapper/MolioUserMapper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct MolioUserMapper: BidirectionalMapper { 4 | typealias Entity = MolioUser 5 | typealias DTO = MolioUserDTO 6 | 7 | static func map(from user: MolioUser) -> MolioUserDTO { 8 | return MolioUserDTO( 9 | id: user.id, 10 | name: user.name, 11 | profileImageURL: user.profileImageURL?.absoluteString, 12 | description: user.description 13 | ) 14 | } 15 | 16 | static func map(from dto: MolioUserDTO) -> MolioUser { 17 | let profileImageURL: URL? 18 | 19 | if let urlString = dto.profileImageURL { 20 | profileImageURL = URL(string: urlString) 21 | } else { 22 | profileImageURL = nil 23 | } 24 | 25 | return MolioUser( 26 | id: dto.id, 27 | name: dto.name, 28 | profileImageURL: profileImageURL, 29 | description: dto.description 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Molio/Source/Data/Mapper/SongMapper.swift: -------------------------------------------------------------------------------- 1 | import MusicKit 2 | 3 | struct SongMapper { 4 | static func toDomain(_ song: Song) -> MolioMusic? { 5 | guard let isrc = song.isrc, 6 | let previewAsset = song.previewAssets?.first?.url else { 7 | return nil 8 | } 9 | 10 | return MolioMusic(title: song.title, 11 | artistName: song.artistName, 12 | gerneNames: song.genreNames, 13 | isrc: isrc, 14 | previewAsset: previewAsset, 15 | artworkImageURL: song.artwork?.url(width: 440, height: 440), 16 | artworkBackgroundColor: song.artwork?.backgroundColor.flatMap { CGColorMapper.toDomain($0) }, 17 | primaryTextColor: song.artwork?.primaryTextColor.flatMap { CGColorMapper.toDomain($0) } 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Molio/Source/Data/Model/DTO/ExternalIDsDTO.swift: -------------------------------------------------------------------------------- 1 | struct ExternalIDsDTO: Decodable { 2 | let isrc: String 3 | } 4 | -------------------------------------------------------------------------------- /Molio/Source/Data/Model/DTO/FollowRelationDTO.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct FollowRelationDTO: Codable { 4 | let id: String // 팔로우 관계 고유 ID 5 | let date: Date // 팔로잉한 시점 6 | let following: String // 팔로잉한 사용자 ID 7 | let follower: String // 팔로워 사용자 ID 8 | let state: Bool // true: 수락 상태, false: 대기 상태 9 | 10 | enum CodingKeys: String, CodingKey { 11 | case id 12 | case date 13 | case following 14 | case follower 15 | case state 16 | } 17 | 18 | func toDictionary() -> [String: Any]? { 19 | guard let data = try? JSONEncoder().encode(self), 20 | let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []), 21 | let dictionary = jsonObject as? [String: Any] else { 22 | return nil 23 | } 24 | return dictionary 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Molio/Source/Data/Model/DTO/MolioPlaylistDTO.swift: -------------------------------------------------------------------------------- 1 | import FirebaseCore 2 | 3 | struct MolioPlaylistDTO: Codable { 4 | let id: String // 플레이리스트 고유 ID 5 | let authorID: String? // 플레이리스트 작성자 ID 6 | var title: String // 플레이리스트 이름 7 | let createdAt: Timestamp // 생성일 8 | var filters: [String] // 필터 9 | var musicISRCs: [String] // 음악 ISRC 리스트 10 | var likes: [String] // 좋아요를 누른 사용자 ID 리스트 11 | 12 | func toDictionary() -> [String: Any]? { 13 | guard let data = try? JSONEncoder().encode(self), 14 | let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []), 15 | let dictionary = jsonObject as? [String: Any] else { 16 | return nil 17 | } 18 | return dictionary 19 | } 20 | 21 | func copy( 22 | id: String? = nil, 23 | authorID: String? = nil, 24 | title: String? = nil, 25 | createdAt: Timestamp? = nil, 26 | filters: [String]? = nil, 27 | musicISRCs: [String]? = nil, 28 | likes: [String]? = nil 29 | ) -> MolioPlaylistDTO { 30 | return MolioPlaylistDTO( 31 | id: id ?? self.id, 32 | authorID: authorID ?? self.authorID, 33 | title: title ?? self.title, 34 | createdAt: createdAt ?? self.createdAt, 35 | filters: filters ?? self.filters, 36 | musicISRCs: musicISRCs ?? self.musicISRCs, 37 | likes: likes ?? self.likes 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Molio/Source/Data/Model/DTO/MolioUserDTO.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct MolioUserDTO: Codable { 4 | let id: String // 유저의 고유 ID 5 | let name: String // 유저 닉네임 6 | let profileImageURL: String? // 유저의 사진 URL 7 | let description: String? // 유저의 한 줄 설명 8 | 9 | enum CodingKeys: String, CodingKey { 10 | case id 11 | case name 12 | case profileImageURL 13 | case description 14 | } 15 | 16 | func toDictionary() -> [String: Any]? { 17 | guard let data = try? JSONEncoder().encode(self), 18 | let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []), 19 | let dictionary = jsonObject as? [String: Any] else { 20 | return nil 21 | } 22 | return dictionary 23 | } 24 | 25 | var toEntity: MolioUser { 26 | let profileImageURL: URL? 27 | if let urlString = self.profileImageURL { 28 | profileImageURL = URL(string: urlString) 29 | } else { 30 | profileImageURL = nil 31 | } 32 | return MolioUser( 33 | id: self.id, 34 | name: self.name, 35 | profileImageURL: profileImageURL, 36 | description: self.description 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Molio/Source/Data/Model/DTO/RecommendationsResponseDTO.swift: -------------------------------------------------------------------------------- 1 | struct RecommendationsResponseDTO: Decodable { 2 | let tracks: [TrackDTO] 3 | } 4 | -------------------------------------------------------------------------------- /Molio/Source/Data/Model/DTO/SpotifyAccessTokenResponseDTO.swift: -------------------------------------------------------------------------------- 1 | struct SpotifyAccessTokenResponseDTO: Decodable { 2 | let accessToken: String 3 | let tokenType: String 4 | let expiresIn: Int 5 | 6 | enum CodingKeys: String, CodingKey { 7 | case accessToken = "access_token" 8 | case tokenType = "token_type" 9 | case expiresIn = "expires_in" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Molio/Source/Data/Model/DTO/SpotifyAvailableGenreSeedsDTO.swift: -------------------------------------------------------------------------------- 1 | struct SpotifyAvailableGenreSeedsDTO: Decodable { 2 | let genres: [String] 3 | } 4 | -------------------------------------------------------------------------------- /Molio/Source/Data/Model/DTO/TrackDTO.swift: -------------------------------------------------------------------------------- 1 | struct TrackDTO: Decodable { 2 | let externalIDs: ExternalIDsDTO 3 | 4 | enum CodingKeys: String, CodingKey { 5 | case externalIDs = "external_ids" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Molio/Source/Data/Model/DataModel/MolioModel.xcdatamodeld/MolioModel.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Molio/Source/Data/Model/DataModel/Playlist+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Playlist+CoreDataClass.swift 3 | // Molio 4 | // 5 | // Created by p_kxn_g on 11/17/24. 6 | // 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | @objc(Playlist) 13 | public class Playlist: NSManagedObject { 14 | 15 | } 16 | -------------------------------------------------------------------------------- /Molio/Source/Data/Model/DataModel/Playlist+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | 4 | extension Playlist { 5 | 6 | @nonobjc public class func fetchRequest() -> NSFetchRequest { 7 | return NSFetchRequest(entityName: "Playlist") 8 | } 9 | 10 | @NSManaged public var createdAt: Date 11 | @NSManaged public var filters: [String] 12 | @NSManaged public var id: UUID 13 | @NSManaged public var musicISRCs: [String] 14 | @NSManaged public var name: String 15 | 16 | } 17 | 18 | extension Playlist: Identifiable {} 19 | -------------------------------------------------------------------------------- /Molio/Source/Data/Model/DataModel/PlaylistModel.xcdatamodeld/Playlist.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Molio/Source/Data/Repository/DefaultAuthStateRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class DefaultAuthStateRepository: AuthStateRepository { 4 | private var authLocalStorage: AuthLocalStorage 5 | private let firebaseService: AuthService 6 | 7 | init( 8 | authLocalStorage: AuthLocalStorage = DIContainer.shared.resolve(), 9 | firebaseService: AuthService = DIContainer.shared.resolve() 10 | ) { 11 | self.authLocalStorage = authLocalStorage 12 | self.firebaseService = firebaseService 13 | } 14 | 15 | var authMode: AuthMode { 16 | return authLocalStorage.authMode 17 | } 18 | 19 | var authSelection: AuthSelection { 20 | return authLocalStorage.authSelection 21 | } 22 | 23 | func setAuthMode(_ mode: AuthMode) { 24 | authLocalStorage.authMode = mode 25 | } 26 | 27 | func setAuthSelection(_ selection: AuthSelection) { 28 | authLocalStorage.authSelection = selection 29 | } 30 | 31 | func signInApple(info: AppleAuthInfo) async throws -> (uid: String, isNewUser: Bool) { 32 | return try await firebaseService.signInApple(info: info) 33 | } 34 | 35 | func logout() throws { 36 | try firebaseService.logout() 37 | } 38 | 39 | func reauthenticateApple(idToken: String, nonce: String) async throws { 40 | try await firebaseService.reauthenticateApple(idToken: idToken, nonce: nonce) 41 | } 42 | 43 | func deleteAuth(authorizationCode: String) async throws { 44 | try await firebaseService.deleteAccount(authorizationCode: authorizationCode) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Molio/Source/Data/Repository/DefaultCurrentPlaylistRepository.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | final class DefaultCurrentPlaylistRepository: CurrentPlaylistRepository { 5 | // MARK: - Properties 6 | private let defaults: UserDefaults 7 | private lazy var currentPlaylistSubject: CurrentValueSubject = { 8 | if let uuid = getUUID(forKey: .currentPlaylist) { 9 | return CurrentValueSubject(uuid) 10 | } else if let uuid = getUUID(forKey: .defaultPlaylist) { 11 | return CurrentValueSubject(uuid) 12 | } else { 13 | return CurrentValueSubject(nil) 14 | // TODO: UseCase에서 default와 current 저장해주기. 15 | } 16 | }() 17 | var currentPlaylistPublisher: AnyPublisher { 18 | currentPlaylistSubject.eraseToAnyPublisher() 19 | } 20 | 21 | // MARK: - Initializer 22 | init(userDefaults: UserDefaults = .standard) { 23 | self.defaults = userDefaults 24 | } 25 | 26 | // MARK: - Methods 27 | func setCurrentPlaylist(_ id: UUID) throws { 28 | try setIdToUserDefaults(id, key: .currentPlaylist) 29 | } 30 | 31 | func setDefaultPlaylist(_ id: UUID) throws { 32 | try setIdToUserDefaults(id, key: .defaultPlaylist) 33 | } 34 | 35 | // MARK: - Private Methods 36 | private func setIdToUserDefaults(_ id: UUID, key: UserDefaultKey) throws { 37 | let idString = id.uuidString 38 | 39 | defaults.set(idString, forKey: key.rawValue) 40 | 41 | if key == .currentPlaylist { 42 | currentPlaylistSubject.send(id) 43 | } 44 | } 45 | 46 | private func getUUID(forKey key: UserDefaultKey) -> UUID? { 47 | do { 48 | let idString = try getIdFromUserDefaults(key: key) 49 | return UUID(uuidString: idString) 50 | } catch { 51 | return nil 52 | } 53 | } 54 | 55 | private func getIdFromUserDefaults (key: UserDefaultKey) throws -> String { 56 | guard let idString = defaults.string(forKey: key.rawValue) else { 57 | throw UserDefaultsError.notFound 58 | } 59 | return idString 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Molio/Source/Data/Repository/DefaultImageRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct DefaultImageRepository: ImageRepository { 4 | private let imageFetchService: ImageFetchService 5 | 6 | init( 7 | imageFetchService: ImageFetchService = DIContainer.shared.resolve() 8 | ) { 9 | self.imageFetchService = imageFetchService 10 | } 11 | 12 | func fetchImage(from url: URL) async throws -> Data { 13 | return try await imageFetchService.fetchImage(from: url) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Molio/Source/Data/Repository/DefaultRecommendedMusicRepository.swift: -------------------------------------------------------------------------------- 1 | struct DefaultRecommendedMusicRepository: RecommendedMusicRepository { 2 | private let musicKitService: MusicKitService 3 | 4 | init( 5 | musicKitService: MusicKitService = DIContainer.shared.resolve() 6 | ) { 7 | self.musicKitService = musicKitService 8 | } 9 | 10 | func fetchMusicGenres() async throws -> [MusicGenre] { 11 | return try await musicKitService.fetchGenres() 12 | } 13 | 14 | func fetchRecommendedMusics(with genres: [MusicGenre]) async throws -> [MolioMusic] { 15 | return try await musicKitService.fetchRecommendedMusics(by: genres) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Molio/Source/DesignSystem/PretendardFontName.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum PretendardFontName { 4 | static let Black = "Pretendard-Black" 5 | static let Bold = "Pretendard-Bold" 6 | static let ExtraBold = "Pretendard-ExtraBold" 7 | static let SemiBold = "Pretendard-SemiBold" 8 | static let Medium = "Pretendard-Medium" 9 | static let Regular = "Pretendard-Regular" 10 | static let Thin = "Pretendard-Thin" 11 | static let Light = "Pretendard-Light" 12 | static let ExtraLight = "Pretendard-ExtraLight" 13 | } 14 | -------------------------------------------------------------------------------- /Molio/Source/Domain/Entity/Auth/AppleAuthInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct AppleAuthInfo { 4 | let idToken: String 5 | let nonce: String 6 | let fullName: PersonNameComponents? 7 | } 8 | -------------------------------------------------------------------------------- /Molio/Source/Domain/Entity/Auth/AuthMode.swift: -------------------------------------------------------------------------------- 1 | /// 사용자가 선택한 인증 모드를 나타내는 상태 값입니다. 2 | enum AuthMode: String { 3 | /// 로그인 계정으로 앱을 사용하는 상태 4 | case authenticated 5 | 6 | /// 비로그인 계정으로 앱을 사용하는 상태 7 | case guest 8 | } 9 | -------------------------------------------------------------------------------- /Molio/Source/Domain/Entity/Auth/AuthSelection.swift: -------------------------------------------------------------------------------- 1 | /// 사용자가 앱 사용 방식(로그인/비로그인)을 선택했는지 여부를 나타내는 상태 값입니다. 2 | enum AuthSelection: String { 3 | /// 사용자가 로그인/비로그인 방식을 선택한 상태 4 | case selected 5 | 6 | /// 사용자가 앱 사용 방식을 아직 선택하지 않은 초기 상태 7 | case unselected 8 | } 9 | -------------------------------------------------------------------------------- /Molio/Source/Domain/Entity/MolioFollowRelation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct MolioFollowRelation: Identifiable { 4 | let id: String // 팔로우 관계 고유 ID 5 | let date: Date // 팔로잉한 시점 6 | let following: String // 팔로잉한 사용자 ID 7 | let follower: String // 팔로워 사용자 ID 8 | var state: Bool // true: 수락 상태, false: 대기 상태 9 | } 10 | -------------------------------------------------------------------------------- /Molio/Source/Domain/Entity/MolioFollower.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 팔로워 리스트에서 보여주는 엔티티 4 | struct MolioFollower: Identifiable { 5 | let id: String // 유저의 고유 ID 6 | var name: String // 유저 닉네임 7 | var profileImageURL: URL? // 유저의 사진 URL 8 | var description: String? // 유저의 한 줄 설명 9 | var followRelation: FollowRelationType 10 | } 11 | extension MolioFollower { 12 | static let mock = MolioFollower( 13 | id: "", 14 | name: "홍길도", 15 | profileImageURL: URL(string: "https://picsum.photos/200/300"), 16 | description: "더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽", // 50자 17 | followRelation: .following 18 | ) 19 | static let mockArray = [ 20 | MolioFollower( 21 | id: "1", 22 | name: "홍길도", 23 | profileImageURL: URL(string: "https://picsum.photos/200/300"), 24 | description: "더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽", // 50자 25 | followRelation: .following 26 | ), 27 | MolioFollower( 28 | id: "2", 29 | name: "홍길동", 30 | profileImageURL: URL(string: "https://picsum.photos/200/300"), 31 | description: "더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽", // 50자 32 | followRelation: .following 33 | ), 34 | MolioFollower( 35 | id: "3", 36 | name: "홍길두", 37 | profileImageURL: URL(string: "https://picsum.photos/200/300"), 38 | description: "더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽", // 50자 39 | followRelation: .following 40 | ), 41 | MolioFollower( 42 | id: "4", 43 | name: "홍길둥", 44 | profileImageURL: URL(string: "https://picsum.photos/200/300"), 45 | description: "더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽", // 50자 46 | followRelation: .following 47 | ), 48 | MolioFollower( 49 | id: "5", 50 | name: "길동", 51 | profileImageURL: URL(string: "https://picsum.photos/200/300"), 52 | description: "더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽", // 50자 53 | followRelation: .following 54 | ) 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /Molio/Source/Domain/Entity/MolioMusic.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Swipe 할 수 있는 카드 정보에 표시되는 음악 정보에 대한 Entity입니다. 4 | struct MolioMusic: Equatable { 5 | /// 노래 제목 6 | let title: String 7 | 8 | /// 아티스트 이름 9 | let artistName: String 10 | 11 | /// 장르 목록 12 | let gerneNames: [String] 13 | 14 | /// 국제 표준 녹음 자료 코드 15 | let isrc: String 16 | 17 | /// 음악 미리듣기 URL 18 | let previewAsset: URL 19 | 20 | /// 앨범 아트워크 이미지 21 | let artworkImageURL: URL? 22 | 23 | /// 앨범에 이미지에 따른 평균 배경색 24 | let artworkBackgroundColor: RGBAColor? 25 | 26 | /// 앨범에 이미지에 따른 primary 색상 27 | let primaryTextColor: RGBAColor? 28 | 29 | // Equatable 30 | 31 | static func == (lhs: MolioMusic, rhs: MolioMusic) -> Bool { 32 | return lhs.isrc == rhs.isrc 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Molio/Source/Domain/Entity/MolioUser.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct MolioUser: Identifiable { 4 | let id: String // 유저의 고유 ID 5 | var name: String // 유저 닉네임 6 | var profileImageURL: URL? // 유저의 사진 URL 7 | var description: String? // 유저의 한 줄 설명 8 | 9 | func convertToFollower(followRelation: FollowRelationType) -> MolioFollower { 10 | return MolioFollower( 11 | id: self.id, 12 | name: self.name, 13 | profileImageURL: self.profileImageURL, 14 | description: self.description, 15 | followRelation: followRelation 16 | ) 17 | } 18 | } 19 | 20 | extension MolioUser { 21 | static let mock = MolioUser( 22 | id: "", 23 | name: "홍길도", 24 | profileImageURL: URL(string: "https://picsum.photos/200/300"), 25 | description: "더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽" // 50자 26 | ) 27 | static let mockArray = [ 28 | MolioUser( 29 | id: "1", 30 | name: "홍길도", 31 | profileImageURL: URL(string: "https://picsum.photos/200/300"), 32 | description: "더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽" // 50자 33 | ), 34 | MolioUser( 35 | id: "2", 36 | name: "홍길동", 37 | profileImageURL: URL(string: "https://picsum.photos/200/300"), 38 | description: "더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽" // 50자 39 | ), 40 | MolioUser( 41 | id: "3", 42 | name: "홍길두", 43 | profileImageURL: URL(string: "https://picsum.photos/200/300"), 44 | description: "더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽" // 50자 45 | ), 46 | MolioUser( 47 | id: "4", 48 | name: "홍길둥", 49 | profileImageURL: URL(string: "https://picsum.photos/200/300"), 50 | description: "더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽" // 50자 51 | ), 52 | MolioUser( 53 | id: "5", 54 | name: "길동", 55 | profileImageURL: URL(string: "https://picsum.photos/200/300"), 56 | description: "더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽게긴설명더럽" // 50자 57 | ) 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /Molio/Source/Domain/Entity/MusicGenre.swift: -------------------------------------------------------------------------------- 1 | typealias MusicGenre = String 2 | -------------------------------------------------------------------------------- /Molio/Source/Domain/Entity/RGBAColor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 음악 정보의 색상을 표현하는 Entity입니다. 4 | struct RGBAColor { 5 | let red: CGFloat 6 | let green: CGFloat 7 | let blue: CGFloat 8 | let alpha: CGFloat 9 | 10 | static let background = RGBAColor( 11 | red: 50/255, 12 | green: 50/255, 13 | blue: 50/255, 14 | alpha: 1.0 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /Molio/Source/Domain/Entity/RandomMusicDeck/RandomMusicDeck.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | protocol RandomMusicDeck { 4 | var currentMusic: MolioMusic? { get } 5 | 6 | var currentMusicTrackModelPublisher: AnyPublisher { get } 7 | 8 | var nextMusicTrackModelPublisher: AnyPublisher { get } 9 | 10 | var isPreparingMusicDeckPublisher: AnyPublisher { get } 11 | 12 | func likeCurrentMusic() 13 | 14 | func dislikeCurrentMusic() 15 | 16 | func reset(with filter: [MusicGenre]) 17 | } 18 | -------------------------------------------------------------------------------- /Molio/Source/Domain/RepositoryInterface/AuthStateRepository.swift: -------------------------------------------------------------------------------- 1 | protocol AuthStateRepository { 2 | var authMode: AuthMode { get } 3 | var authSelection: AuthSelection { get } 4 | func setAuthMode(_ mode: AuthMode) 5 | func setAuthSelection(_ selection: AuthSelection) 6 | func signInApple(info: AppleAuthInfo) async throws -> (uid: String, isNewUser: Bool) 7 | func logout() throws 8 | func reauthenticateApple(idToken: String, nonce: String) async throws 9 | func deleteAuth(authorizationCode: String) async throws 10 | } 11 | -------------------------------------------------------------------------------- /Molio/Source/Domain/RepositoryInterface/CurrentPlaylistRepository.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | protocol CurrentPlaylistRepository { 5 | var currentPlaylistPublisher: AnyPublisher { get } 6 | func setCurrentPlaylist(_ id: UUID) throws 7 | func setDefaultPlaylist(_ id: UUID) throws 8 | } 9 | -------------------------------------------------------------------------------- /Molio/Source/Domain/RepositoryInterface/ImageRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol ImageRepository { 4 | func fetchImage(from url: URL) async throws -> Data 5 | } 6 | -------------------------------------------------------------------------------- /Molio/Source/Domain/RepositoryInterface/PlaylistRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | protocol PlaylistRepository { 5 | func addMusic(userID: String?, isrc: String, to playlistID: UUID) async throws 6 | func deleteMusic(userID: String?, isrc: String, in playlistID: UUID) async throws 7 | func moveMusic(userID: String?, isrc: String, in playlistID: UUID, fromIndex: Int, toIndex: Int) async throws 8 | func createNewPlaylist(userID: String?, playlistID: UUID, _ playlistName: String) async throws 9 | func deletePlaylist(userID: String?, _ playlistID: UUID) async throws 10 | func fetchPlaylists(userID: String?) async throws -> [MolioPlaylist]? 11 | func fetchPlaylist(userID: String?, for playlistID: UUID) async throws -> MolioPlaylist? 12 | func updatePlaylist(userID: String?, newPlaylist: MolioPlaylist) async throws 13 | } 14 | -------------------------------------------------------------------------------- /Molio/Source/Domain/RepositoryInterface/RecommendedMusicRepository.swift: -------------------------------------------------------------------------------- 1 | protocol RecommendedMusicRepository { 2 | /// 넘겨받은 필터에 기반하여 추천된 랜덤 음악 목록을 가져옵니다. 3 | /// - Parameters: 추천받을 랜덤 음악에 대한 필터 (플레이리스트 필터) 4 | /// - Returns: `RandomMusic`의 배열 5 | func fetchMusicGenres() async throws -> [MusicGenre] 6 | func fetchRecommendedMusics(with genres: [MusicGenre]) async throws -> [MolioMusic] 7 | } 8 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/AppleMusicUseCase/AppleMusicUseCase.swift: -------------------------------------------------------------------------------- 1 | protocol AppleMusicUseCase { 2 | func checkSubscription() async throws -> Bool 3 | func exportPlaylist(_ playlist: MolioPlaylist) async throws -> String? 4 | } 5 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/AppleMusicUseCase/DefaultAppleMusicUseCase.swift: -------------------------------------------------------------------------------- 1 | struct DefaultAppleMusicUseCase: AppleMusicUseCase { 2 | private let musicKitService: MusicKitService 3 | 4 | init( 5 | musicKitService: MusicKitService = DIContainer.shared.resolve() 6 | ) { 7 | self.musicKitService = musicKitService 8 | } 9 | 10 | func checkSubscription() async throws -> Bool { 11 | try await musicKitService.checkSubscriptionStatus() 12 | } 13 | 14 | func exportPlaylist(_ playlist: MolioPlaylist) async throws -> String? { 15 | return try await musicKitService.exportAppleMusicPlaylist(name: playlist.name, isrcs: playlist.musicISRCs) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/CommunityUseCase/CommunityUseCase.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol CommunityUseCase { 4 | func likePlaylist(playlistID: UUID) async throws 5 | func unlikePlaylist(playlistID: UUID) async throws 6 | } 7 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/CommunityUseCase/DefaultCommunityUseCase.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class DefaultCommunityUseCase: 4 | CommunityUseCase { 5 | private let currentUserIdUseCase: CurrentUserIdUseCase 6 | private let playlistRepository: PlaylistRepository 7 | 8 | init( 9 | currentUserIdUseCase: CurrentUserIdUseCase, 10 | playlistRepository: PlaylistRepository 11 | ) { 12 | self.currentUserIdUseCase = currentUserIdUseCase 13 | self.playlistRepository = playlistRepository 14 | } 15 | 16 | func likePlaylist(playlistID: UUID) async throws { 17 | guard let userID = try currentUserIdUseCase.execute(), 18 | let playlist = try await playlistRepository.fetchPlaylist(userID: userID, for: playlistID) else { return } 19 | 20 | var updatedLike = playlist.like 21 | updatedLike.append(userID) 22 | let newPlaylist = playlist.copy(like: updatedLike) 23 | try await playlistRepository.updatePlaylist(userID: userID, newPlaylist: newPlaylist) 24 | } 25 | 26 | func unlikePlaylist(playlistID: UUID) async throws { 27 | guard let userID = try currentUserIdUseCase.execute(), 28 | let playlist = try await playlistRepository.fetchPlaylist(userID: userID, for: playlistID) else { return } 29 | 30 | var updatedLike = playlist.like 31 | updatedLike.removeAll { $0 == userID } 32 | 33 | let newPlaylist = playlist.copy(like: updatedLike) 34 | try await playlistRepository.updatePlaylist(userID: userID, newPlaylist: newPlaylist) 35 | } 36 | } 37 | 38 | enum PlaylistLikeError: Error { 39 | case likeNotFound 40 | } 41 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/CurrentUserIdUseCase/CurrentUserIdUseCase.swift: -------------------------------------------------------------------------------- 1 | protocol CurrentUserIdUseCase { 2 | func execute() throws -> String? 3 | } 4 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/CurrentUserIdUseCase/DefaultCurrentUserIdUseCase.swift: -------------------------------------------------------------------------------- 1 | struct DefaultCurrentUserIdUseCase: CurrentUserIdUseCase { 2 | private let authService: AuthService 3 | private let usecase: ManageAuthenticationUseCase 4 | 5 | init( 6 | authService: AuthService = DIContainer.shared.resolve(), 7 | usecase: ManageAuthenticationUseCase = DIContainer.shared.resolve() 8 | ) { 9 | self.authService = authService 10 | self.usecase = usecase 11 | } 12 | // TODO: 로그인 재인증 로직 (로그인 화면으로 이동, 재인증 후 다시 불러오기 등) 처리 13 | func execute() throws -> String? { 14 | if usecase.isLogin() { 15 | do { 16 | return try authService.getCurrentID() 17 | } catch { 18 | print(error.localizedDescription) 19 | } 20 | } 21 | return nil 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/FetchAvailableGenresUseCase/Implementation/DefaultFetchAvailableGenresUseCase.swift: -------------------------------------------------------------------------------- 1 | struct DefaultFetchAvailableGenresUseCase: FetchAvailableGenresUseCase { 2 | private let recommendedMusicRepository: RecommendedMusicRepository 3 | 4 | init( 5 | recommendedMusicRepository: RecommendedMusicRepository = DIContainer.shared.resolve() 6 | ) { 7 | self.recommendedMusicRepository = recommendedMusicRepository 8 | } 9 | 10 | func execute() async throws -> [MusicGenre] { 11 | return try await recommendedMusicRepository.fetchMusicGenres() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/FetchAvailableGenresUseCase/Implementation/MockDefaultFetchAvailableGenresUseCase.swift: -------------------------------------------------------------------------------- 1 | struct MockFetchAvailableGenresUseCase: FetchAvailableGenresUseCase { 2 | var musicGenreArrToReturn: [MusicGenre] 3 | 4 | init( 5 | musicGenreArrToReturn: [MusicGenre] = [ 6 | "음악", "얼터너티브", "블루스", "크리스천", "클래식", "컨트리", "댄스", 7 | "일렉트로닉", "힙합/랩", "홀리데이", "재즈", "K-Pop", "어린이 음악" 8 | ] 9 | ) { 10 | self.musicGenreArrToReturn = musicGenreArrToReturn 11 | } 12 | 13 | func execute() async throws -> [MusicGenre] { 14 | return musicGenreArrToReturn 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/FetchAvailableGenresUseCase/Protocol/FetchAvailableGenresUseCase.swift: -------------------------------------------------------------------------------- 1 | protocol FetchAvailableGenresUseCase { 2 | func execute() async throws -> [MusicGenre] 3 | } 4 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/FetchImageUsecase/Implementation/DefaultFetchImageUseCase.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct DefaultFetchImageUseCase: FetchImageUseCase { 4 | private let repository: ImageRepository 5 | 6 | init( 7 | repository: ImageRepository = DIContainer.shared.resolve() 8 | ) { 9 | self.repository = repository 10 | } 11 | 12 | func execute(url: URL) async throws -> Data { 13 | return try await repository.fetchImage(from: url) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/FetchImageUsecase/Protocol/FetchImageUseCase.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol FetchImageUseCase { 4 | func execute(url: URL) async throws -> Data 5 | } 6 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/FetchRecommendedMusicUseCase/DefaultFetchRecommendedMusicUseCase.swift: -------------------------------------------------------------------------------- 1 | struct DefaultFetchRecommendedMusicUseCase: FetchRecommendedMusicUseCase { 2 | private let musicRepository: RecommendedMusicRepository 3 | 4 | init( 5 | repository: RecommendedMusicRepository = DIContainer.shared.resolve() 6 | ) { 7 | self.musicRepository = repository 8 | } 9 | 10 | func execute(with filter: [MusicGenre]) async throws -> [MolioMusic] { 11 | return try await musicRepository.fetchRecommendedMusics(with: filter) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/FetchRecommendedMusicUseCase/FetchRecommendedMusicUseCase.swift: -------------------------------------------------------------------------------- 1 | protocol FetchRecommendedMusicUseCase { 2 | func execute(with filter: [MusicGenre]) async throws -> [MolioMusic] 3 | } 4 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/FollowRelationUseCase/FollowRelationUseCase.swift: -------------------------------------------------------------------------------- 1 | protocol FollowRelationUseCase { 2 | func requestFollowing(to targetID: String) async throws 3 | func approveFollowing(relationID: String) async throws 4 | func refuseFollowing(relationID: String) async throws 5 | func unFollow(to targetID: String) async throws 6 | func fetchFollowRelation(for userID: String) async throws -> FollowRelationType 7 | func fetchFollower(userID: String) async throws -> MolioFollower 8 | func fetchAllFollowers() async throws -> [MolioFollower] 9 | func fetchMyFollowingList() async throws -> [MolioFollower] 10 | func fetchFriendFollowingList(friendID: String) async throws -> [MolioFollower] 11 | func fetchMyFollowerList() async throws -> [MolioFollower] 12 | func fetchFriendFollowerList(friendID: String) async throws -> [MolioFollower] 13 | } 14 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/ManageAuthenticationUseCase/DefaultManageAuthenticationUseCase.swift: -------------------------------------------------------------------------------- 1 | struct DefaultManageAuthenticationUseCase: ManageAuthenticationUseCase { 2 | private let authStateRepository: AuthStateRepository 3 | 4 | init(authStateRepository: AuthStateRepository = DIContainer.shared.resolve()) { 5 | self.authStateRepository = authStateRepository 6 | } 7 | 8 | func isAuthModeSelected() -> Bool { 9 | return authStateRepository.authSelection == .selected 10 | } 11 | 12 | func isLogin() -> Bool { 13 | switch authStateRepository.authMode { 14 | case .authenticated: 15 | return true 16 | case .guest: 17 | return false 18 | } 19 | } 20 | 21 | func signInApple(info: AppleAuthInfo) async throws -> (uid: String, isNewUser: Bool) { 22 | let (uid, isNewUser) = try await authStateRepository.signInApple(info: info) 23 | authStateRepository.setAuthMode(.authenticated) 24 | authStateRepository.setAuthSelection(.selected) 25 | return (uid, isNewUser) 26 | } 27 | 28 | func loginGuest() { 29 | authStateRepository.setAuthMode(.guest) 30 | authStateRepository.setAuthSelection(.selected) 31 | } 32 | 33 | func logout() throws { 34 | try authStateRepository.logout() 35 | authStateRepository.setAuthMode(.guest) 36 | } 37 | 38 | func deleteAuth(idToken: String, nonce: String, authorizationCode: String) async throws { 39 | try await authStateRepository.reauthenticateApple(idToken: idToken, nonce: nonce) 40 | try await authStateRepository.deleteAuth(authorizationCode: authorizationCode) 41 | authStateRepository.setAuthSelection(.unselected) 42 | authStateRepository.setAuthMode(.guest) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/ManageAuthenticationUseCase/ManageAuthenticationUseCase.swift: -------------------------------------------------------------------------------- 1 | protocol ManageAuthenticationUseCase { 2 | func isAuthModeSelected() -> Bool 3 | func isLogin() -> Bool 4 | func signInApple(info: AppleAuthInfo) async throws -> (uid: String, isNewUser: Bool) 5 | func loginGuest() 6 | func logout() throws 7 | func deleteAuth(idToken: String, nonce: String, authorizationCode: String) async throws 8 | } 9 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/ManageMyPlaylistUseCase/ManageMyPlaylistUseCase.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | protocol ManageMyPlaylistUseCase { 5 | // 현재 선택된 플레이리스트 6 | func currentPlaylistPublisher() -> AnyPublisher 7 | func changeCurrentPlaylist(playlistID: UUID) 8 | 9 | // 플레이리스트 관리 10 | func createPlaylist(playlistName: String) async throws 11 | func updatePlaylistName(playlistID: UUID, name: String) async throws 12 | func updatePlaylistFilter(playlistID: UUID, filter: [MusicGenre]) async throws 13 | func deletePlaylist(playlistID: UUID) async throws 14 | 15 | // 음악 관리 16 | func addMusic(musicISRC: String, to playlistID: UUID) async throws 17 | func deleteMusic(musicISRC: String, from playlistID: UUID) async throws 18 | func moveMusic(musicISRC: String, in playlistID: UUID, fromIndex: Int, toIndex: Int) async throws 19 | } 20 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/RealPlaylistUseCase/FetchPlaylistUseCase.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol FetchPlaylistUseCase { 4 | func fetchMyAllPlaylists() async throws -> [MolioPlaylist] 5 | func fetchMyPlaylist(playlistID: UUID) async throws -> MolioPlaylist 6 | func fetchAllMyMusicIn(playlistID: UUID) async throws -> [MolioMusic] 7 | 8 | func fetchFriendAllPlaylists(friendUserID: String) async throws -> [MolioPlaylist] 9 | func fetchFriendPlaylist(friendUserID: String, playlistID: UUID) async throws -> MolioPlaylist 10 | func fetchAllFriendMusics(friendUserID: String, playlistID: UUID) async throws -> [MolioMusic] 11 | } 12 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/RealPlaylistUseCase/FetchPlaylistUseCaseError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum FetchPlaylistUseCaseError: LocalizedError { 4 | case playlistNotFoundWithID 5 | 6 | var errorDescription: String? { 7 | switch self { 8 | case .playlistNotFoundWithID: 9 | return "UUID에 해당하는 플레이리스트를 찾을 수 없습니다" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/UserUseCase/DefaultUserUseCase.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class DefaultUserUseCase: UserUseCase { 4 | private let service: UserService 5 | private let currentUserIdUseCase: CurrentUserIdUseCase 6 | 7 | init( 8 | service: UserService = DIContainer.shared.resolve(), 9 | currentUserIdUseCase: CurrentUserIdUseCase = DIContainer.shared.resolve() 10 | ) { 11 | self.service = service 12 | self.currentUserIdUseCase = currentUserIdUseCase 13 | } 14 | 15 | func createUser(userName: String?) async throws { 16 | guard let userID = try currentUserIdUseCase.execute() else { return } 17 | let newUser = MolioUserDTO( 18 | id: userID, 19 | name: userName ?? "닉네임 없음", 20 | profileImageURL: "", 21 | description: "" 22 | ) 23 | try await service.createUser(newUser) 24 | } 25 | 26 | func fetchUser(userID: String) async throws -> MolioUser { 27 | let userDTO = try await service.readUser(userID: userID) 28 | return userDTO.toEntity 29 | } 30 | 31 | func fetchCurrentUser() async throws -> MolioUser? { 32 | guard let userID = try currentUserIdUseCase.execute() else { return nil } 33 | let userDTO = try await service.readUser(userID: userID) 34 | return userDTO.toEntity 35 | } 36 | 37 | func fetchAllUsers() async throws -> [MolioUser] { 38 | let userDTOs = try await service.readAllUsers() 39 | return userDTOs.map(\.toEntity) 40 | } 41 | 42 | func updateUser( 43 | userID: String, 44 | newName: String, 45 | newDescription: String?, 46 | imageUpdate: ProfileImageUpdateType 47 | ) async throws { 48 | // 이미지 업데이트 관련 49 | let updateImageURLString: String? = switch imageUpdate { 50 | case .keep: 51 | try await service.readUser(userID: userID).profileImageURL 52 | case .update(let imageData): 53 | try await service.uploadUserImage(userID: userID, data: imageData).absoluteString 54 | case .remove: 55 | "" 56 | } 57 | 58 | let updatedUser = MolioUserDTO( 59 | id: userID, 60 | name: newName, 61 | profileImageURL: updateImageURLString, 62 | description: newDescription 63 | ) 64 | 65 | try await service.updateUser(updatedUser) 66 | } 67 | 68 | func deleteUser(userID: String) async throws { 69 | try await service.deleteUser(userID: userID) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/UserUseCase/ProfileImageUpdateType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum ProfileImageUpdateType { 4 | case keep 5 | case update(Data) 6 | case remove 7 | } 8 | -------------------------------------------------------------------------------- /Molio/Source/Domain/UseCase/UserUseCase/UserUseCase.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol UserUseCase { 4 | func createUser(userName: String?) async throws 5 | func fetchCurrentUser() async throws -> MolioUser? 6 | func fetchUser(userID: String) async throws -> MolioUser 7 | func fetchAllUsers() async throws -> [MolioUser] 8 | func updateUser(userID: String, newName: String, newDescription: String?, imageUpdate: ProfileImageUpdateType) async throws 9 | func deleteUser(userID: String) async throws 10 | } 11 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/AudioPlayerControl/AudioPlayerControlView.swift: -------------------------------------------------------------------------------- 1 | import AVKit 2 | import SwiftUI 3 | 4 | struct AudioPlayerControlView: View { 5 | @EnvironmentObject private var viewModel: AudioPlayerControlViewModel 6 | 7 | var body: some View { 8 | HStack { 9 | Spacer() 10 | 11 | Button { 12 | viewModel.backwardButtonTapped() 13 | } label: { 14 | Image.molioRegular(systemName: "backward.fill", size: 24, color: .main) 15 | } 16 | 17 | Spacer() 18 | 19 | Button { 20 | viewModel.playButtonTapped() 21 | } label: { 22 | Image 23 | .molioRegular( 24 | systemName: viewModel.isPlaying ? "pause.fill" : "play.fill", 25 | size: 24, 26 | color: .main 27 | ) 28 | } 29 | 30 | Spacer() 31 | 32 | Button { 33 | viewModel.nextButtonTapped() 34 | } label: { 35 | Image.molioRegular(systemName: "forward.fill", size: 24, color: .main) 36 | } 37 | 38 | Spacer() 39 | } 40 | .frame(maxWidth: .infinity, maxHeight: .infinity) 41 | .background(.gray, in: .capsule) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/Common/Base/InputOutputViewModel.swift: -------------------------------------------------------------------------------- 1 | protocol InputOutputViewModel { 2 | associatedtype Input 3 | associatedtype Output 4 | 5 | func transform(from input: Input) -> Output 6 | } 7 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/Common/Component/BasicButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum ButtonType: String { 4 | case cancel = "취소" 5 | case confirm = "완료" 6 | case next = "다음" 7 | case exportToAppleMusic = "Apple Music 플레이리스트 내보내기" 8 | case exportToImage = "플레이리스트 이미지로 내보내기" 9 | case goToAppleMusic = "Apple Music으로 들으러 가기" 10 | case saveImage = "전체 이미지 저장" 11 | case shareInstagram = "인스타 공유" 12 | case needAppleMusicSubcription = "Apple Music 구독 후 이용 가능합니다." 13 | case didNotSelectPlatform = "플랫폼을 선택해주세요" 14 | case loginRequired = "로그인하러 가기!" 15 | case onBoarding = "이해했어요!" 16 | case startMolio = "몰리오 시작하기!" 17 | } 18 | 19 | struct BasicButton: View { 20 | var type: ButtonType 21 | var isEnabled: Bool = true 22 | var action: () -> Void = {} 23 | 24 | var body: some View { 25 | Button(action: action) { 26 | Text(type.rawValue) 27 | .font(.headline) 28 | .foregroundColor(textColor) 29 | .padding() 30 | .frame(maxWidth: .infinity) 31 | .background(backgroundColor) 32 | .cornerRadius(10) 33 | } 34 | .disabled(!isEnabled) 35 | .opacity(isEnabled ? 1.0 : 0.5) 36 | } 37 | 38 | // 버튼의 배경색을 타입에 따라 변경 39 | private var backgroundColor: Color { 40 | switch type { 41 | case .cancel, .needAppleMusicSubcription, .didNotSelectPlatform: 42 | return Color.white.opacity(0.2) 43 | case .confirm, .next, .exportToAppleMusic, .exportToImage, .saveImage, .shareInstagram, .loginRequired, .onBoarding, .startMolio: 44 | return Color.mainLighter 45 | case .goToAppleMusic: 46 | return Color.background 47 | } 48 | 49 | } 50 | 51 | private var textColor: Color { 52 | switch type { 53 | case .cancel, .needAppleMusicSubcription, .didNotSelectPlatform: 54 | return Color.white 55 | case .confirm, .next, .exportToAppleMusic, .exportToImage, .saveImage, .shareInstagram, .loginRequired, .onBoarding, .startMolio: 56 | return Color.black 57 | case .goToAppleMusic: 58 | return Color.mainLighter 59 | } 60 | } 61 | } 62 | 63 | #Preview { 64 | VStack { 65 | BasicButton(type: .cancel) { 66 | print("취소 버튼 눌림") 67 | }.padding() 68 | 69 | BasicButton(type: .confirm) { 70 | print("완료 버튼 눌림") 71 | }.padding() 72 | }.background(Color.background) 73 | } 74 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/Common/Component/DefaultProfile.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// 기본 프로필 이미지 뷰 4 | struct DefaultProfile: View { 5 | let size: CGFloat 6 | 7 | init(size: CGFloat = 50) { 8 | self.size = size 9 | } 10 | 11 | var body: some View { 12 | Circle() 13 | .fill(Color.gray) 14 | .overlay( 15 | Image(systemName: "person.fill") 16 | .foregroundColor(.white) 17 | ) 18 | .frame(width: size, height: size) 19 | } 20 | } 21 | 22 | #Preview { 23 | DefaultProfile() 24 | } 25 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/Community/FollowRelation/FollowRelationViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class FollowRelationViewController: UIHostingController { 4 | private let viewModel: FollowRelationViewModel 5 | private let isMyProfile: Bool 6 | private let followRelation: FollowRelationType 7 | private let friendUserID: String? 8 | 9 | // MARK: - Initializer 10 | 11 | init(viewModel: FollowRelationViewModel, isMyProfile: Bool, followRelation: FollowRelationType, friendUserID: String?) { 12 | self.viewModel = viewModel 13 | self.isMyProfile = isMyProfile 14 | self.followRelation = followRelation 15 | self.friendUserID = friendUserID 16 | 17 | let followRelationListView = FollowRelationListView(viewModel: viewModel, followRelationType: followRelation, friendUserID: friendUserID) 18 | super.init(rootView: followRelationListView) 19 | 20 | } 21 | // MARK: - Life Cycle 22 | 23 | override func viewDidLoad() { 24 | super.viewDidLoad() 25 | switch followRelation { 26 | case .unfollowing: 27 | navigationItem.title = "팔로워" 28 | case .following: 29 | navigationItem.title = "팔로잉" 30 | } 31 | } 32 | 33 | required init?(coder aDecoder: NSCoder) { 34 | self.isMyProfile = false 35 | self.followRelation = .following 36 | self.friendUserID = nil 37 | 38 | self.viewModel = FollowRelationViewModel() 39 | super.init(coder: aDecoder) 40 | } 41 | 42 | // MARK: - Present Sheet or Navigation 43 | 44 | private func navigateToSettingViewController() { 45 | 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/Community/FollowRelation/Model/FollowRelationType.swift: -------------------------------------------------------------------------------- 1 | enum FollowRelationType: String { 2 | case unfollowing = "팔로우" 3 | case following = "팔로잉" 4 | } 5 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/Community/FollowRelation/View/FollowRelationButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct FollowRelationButton: View { 4 | var type: FollowRelationType 5 | var action: () -> Void = {} 6 | 7 | var body: some View { 8 | Button(action: action) { 9 | Text.molioSemiBold(type.rawValue, size: 13) 10 | .foregroundColor(textColor) 11 | .frame(maxWidth: .infinity, maxHeight: .infinity) 12 | .background(backgroundColor) 13 | .cornerRadius(10) 14 | } 15 | } 16 | 17 | // 버튼의 배경색을 타입에 따라 변경 18 | private var backgroundColor: Color { 19 | switch type { 20 | case .following: 21 | return Color.white.opacity(0.2) 22 | case .unfollowing: 23 | return Color.mainLighter 24 | } 25 | 26 | } 27 | 28 | private var textColor: Color { 29 | switch type { 30 | case .following: 31 | return Color.white 32 | case .unfollowing: 33 | return Color.black 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/Community/FollowRelation/ViewModel/FollowRelationViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | final class FollowRelationViewModel: ObservableObject { 5 | @Published var users: [MolioFollower] = [] 6 | private let followRelationUseCase: FollowRelationUseCase 7 | 8 | init(followRelationUseCase: FollowRelationUseCase = DIContainer.shared.resolve()) { 9 | self.followRelationUseCase = followRelationUseCase 10 | } 11 | 12 | func fetchData(followRelationType: FollowRelationType, friendUserID: String?) async throws { 13 | guard let friendUserID else { 14 | try await fetchMyData(followRelationType: followRelationType) 15 | return 16 | } 17 | try await fetchFriendData(followRelationType: followRelationType, friendUserID: friendUserID) 18 | } 19 | 20 | @MainActor 21 | private func fetchMyData(followRelationType: FollowRelationType) async throws { 22 | switch followRelationType { 23 | case .unfollowing: // 팔로워 24 | users = try await followRelationUseCase.fetchMyFollowerList() 25 | print(users) 26 | case .following: // 팔로잉 27 | users = try await followRelationUseCase.fetchMyFollowingList() 28 | } 29 | } 30 | 31 | @MainActor 32 | private func fetchFriendData(followRelationType: FollowRelationType, friendUserID: String) async throws { 33 | switch followRelationType { 34 | case .unfollowing: // 팔로워 35 | users = try await followRelationUseCase.fetchFriendFollowerList(friendID: friendUserID) 36 | case .following: // 팔로잉 37 | users = try await followRelationUseCase.fetchFriendFollowingList(friendID: friendUserID) 38 | } 39 | } 40 | 41 | /// 팔로우 상태 업데이트 메서드 42 | @MainActor 43 | func updateFollowState(for user: MolioFollower, to type: FollowRelationType, friendUserID: String?) async { 44 | do { 45 | // 서버에 팔로우 상태 업데이트 46 | switch type { 47 | case .following: 48 | try await followRelationUseCase.unFollow(to: user.id) 49 | case .unfollowing: 50 | try await followRelationUseCase.requestFollowing(to: user.id) 51 | } 52 | 53 | try await fetchData(followRelationType: type, friendUserID: friendUserID) 54 | } catch { 55 | print("Failed to update follow state: \(error.localizedDescription)") 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/Community/Notification/NotificationViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class NotificationViewModel: ObservableObject { 4 | @Published var pendingFollowRequests: [MolioFollower] = [ 5 | // MolioFollower(id: UUID().uuidString, name: "김철수", description: "클래식 음악과 재즈를 사랑하는 사람입니다.", followRelation: .following), 6 | // MolioFollower(id: UUID().uuidString, name: "김철수", description: "클래식 음악과 재즈를 사랑하는 사람입니다.", followRelation: .following), 7 | // MolioFollower(id: UUID().uuidString, name: "김철수", description: "클래식 음악과 재즈를 사랑하는 사람입니다.", followRelation: .following), 8 | // MolioFollower(id: UUID().uuidString, name: "김철수", description: "클래식 음악과 재즈를 사랑하는 사람입니다.", followRelation: .following) 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/Community/UserProfile/Model/ProfileType.swift: -------------------------------------------------------------------------------- 1 | enum ProfileType { 2 | case me 3 | case friend(userID: String, isFollowing: FollowRelationType) 4 | } 5 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/Community/UserProfile/View/ProfileImageView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ProfileImageView: View { 4 | let imageURL: URL? 5 | let placeholderIconName: String 6 | let size: CGFloat 7 | 8 | init(imageURL: URL?, placeholderIconName: String = "person.fill", size: CGFloat = 50) { 9 | self.imageURL = imageURL 10 | self.placeholderIconName = placeholderIconName 11 | self.size = size 12 | } 13 | 14 | var body: some View { 15 | if let imageURL = imageURL { 16 | AsyncImage(url: imageURL) { phase in 17 | switch phase { 18 | case .empty: 19 | ProgressView() 20 | .frame(width: size, height: size) 21 | case .success(let image): 22 | image 23 | .resizable() 24 | .scaledToFill() 25 | .frame(width: size, height: size) 26 | .clipShape(Circle()) 27 | case .failure: 28 | DefaultProfile() 29 | @unknown default: 30 | EmptyView() 31 | } 32 | } 33 | } else { 34 | DefaultProfile() 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/Community/UserProfile/ViewController/CommunityViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | 4 | final class CommunityViewController: UIViewController { 5 | // MARK: - Life Cycle 6 | 7 | override func viewDidLoad() { 8 | super.viewDidLoad() 9 | setupUserProfileView() 10 | } 11 | 12 | private func setupUserProfileView() { 13 | let userProfileViewController = UserProfileViewController(profileType: .me) 14 | 15 | addChild(userProfileViewController) 16 | view.addSubview(userProfileViewController.view) 17 | 18 | userProfileViewController.view.translatesAutoresizingMaskIntoConstraints = false 19 | NSLayoutConstraint.activate([ 20 | userProfileViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), 21 | userProfileViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), 22 | userProfileViewController.view.topAnchor.constraint(equalTo: view.topAnchor), 23 | userProfileViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) 24 | ]) 25 | 26 | userProfileViewController.didMove(toParent: self) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/ExportPlaylist/Model/ExportMusicItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// 내 molio 내보내기에 사용되는 Music Item Model입니다. 4 | struct ExportMusicItem { 5 | /// 노래 제목 6 | let title: String 7 | 8 | /// 아티스트 이름 9 | let artistName: String 10 | 11 | /// 앨범 아트워크 이미지 12 | let artworkImageData: Data? 13 | 14 | let uuid: UUID 15 | 16 | init(molioMusic: MolioMusic, imageData: Data?) { 17 | self.title = molioMusic.title 18 | self.artistName = molioMusic.artistName 19 | self.artworkImageData = imageData 20 | self.uuid = UUID() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/ExportPlaylist/Model/ExportPlatform.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// 플레이리스트 내보내기 플랫폼 유형 4 | enum ExportPlatform: String, CaseIterable, Identifiable { 5 | case appleMusic 6 | case image 7 | 8 | var id: String { rawValue } 9 | var image: Image { 10 | switch self { 11 | case .appleMusic: 12 | Image("appleMusicLogo") 13 | case .image: 14 | Image(systemName: "photo") 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/ExportPlaylist/View/ExportAppleMusicPlaylist/ExportAppleMusicPlaylistViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class ExportAppleMusicPlaylistViewController: UIHostingController { 4 | init(viewModel: PlaylistDetailViewModel) { 5 | let exportAppleMusicPlaylistView = ExportAppleMusicPlaylistView(viewModel: viewModel) 6 | super.init(rootView: exportAppleMusicPlaylistView) 7 | 8 | rootView.confirmButtonTapAction = didTapConfirmButton 9 | } 10 | 11 | required init?(coder aDecoder: NSCoder) { 12 | super.init(coder: aDecoder) 13 | } 14 | 15 | private func didTapConfirmButton() { 16 | dismiss(animated: true) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/ExportPlaylist/View/ExportPlaylistImage/ExportPlaylistImageViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class ExportPlaylistImageViewController: UIHostingController { 4 | init(viewModel: ExportPlaylistImageViewModel) { 5 | let exportPlaylistImageView = ExportPlaylistImageView(viewModel: viewModel) 6 | super.init(rootView: exportPlaylistImageView) 7 | rootView.doneButtonTapAction = didTapDoneButton 8 | } 9 | 10 | required init?(coder aDecoder: NSCoder) { 11 | super.init(coder: aDecoder) 12 | } 13 | 14 | private func didTapDoneButton() { 15 | dismiss(animated: true) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/ExportPlaylist/View/ExportPlaylistImage/PlaylistImageMusicItem.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PlaylistImageMusicItem: View { 4 | var musicItems: ExportMusicItem 5 | 6 | init(musicItems: ExportMusicItem) { 7 | self.musicItems = musicItems 8 | } 9 | 10 | var body: some View { 11 | HStack(spacing: 15) { 12 | if let imageData = musicItems.artworkImageData, 13 | let uiImage = UIImage(data: imageData) { 14 | Image(uiImage: uiImage) 15 | .resizable() 16 | .scaledToFit() 17 | .frame(width: 42, height: 42) 18 | .padding(.top, 6) 19 | .padding(.bottom, 6) 20 | .padding(.leading, 16) 21 | } else { 22 | Color.background 23 | .frame(width: 42, height: 42) 24 | .padding(.top, 6) 25 | .padding(.bottom, 6) 26 | .padding(.leading, 16) 27 | } 28 | VStack(alignment: .leading) { 29 | Text(musicItems.title) 30 | .font(.body) 31 | Text(musicItems.artistName) 32 | .font(.footnote) 33 | .fontWeight(.ultraLight) 34 | } 35 | Spacer() 36 | } 37 | .frame( 38 | minWidth: 0, 39 | maxWidth: .infinity, 40 | minHeight: 0, 41 | maxHeight: 56 42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/ExportPlaylist/View/ExportPlaylistImage/PlaylistImagePage.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PlaylistImagePage: View { 4 | let musicItems: [ExportMusicItem] 5 | 6 | var body: some View { 7 | VStack(spacing: 0) { 8 | ForEach(musicItems, id: \.uuid) { item in 9 | PlaylistImageMusicItem(musicItems: item) 10 | .background(Color.white) 11 | .foregroundStyle(.black) 12 | Divider() 13 | .padding(.leading, 74) 14 | } 15 | } 16 | .background(Color.white) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/ExportPlaylist/View/PlatformSelection/PlatformSelectionViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class PlatformSelectionViewController: UIHostingController { 4 | weak var delegate: PlatformSelectionViewControllerDelegate? 5 | 6 | init(viewModel: PlaylistDetailViewModel) { 7 | let platformSelectionView = PlatformSelectionView(viewModel: viewModel) 8 | super.init(rootView: platformSelectionView) 9 | 10 | rootView.exportButtonTapAction = { [weak self] platform in 11 | self?.delegate?.didSelectPlatform(with: platform) 12 | } 13 | } 14 | 15 | required init?(coder aDecoder: NSCoder) { 16 | super.init(coder: aDecoder) 17 | } 18 | } 19 | 20 | protocol PlatformSelectionViewControllerDelegate: AnyObject { 21 | func didSelectPlatform(with platform: ExportPlatform) 22 | } 23 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/FriendPlaylistDetailView/Delegate/ExportFriendsMusicToMyPlaylistDelegate.swift: -------------------------------------------------------------------------------- 1 | protocol ExportFriendsMusicToMyPlaylistDelegate: AnyObject { 2 | func exportFriendsMusicToMyPlaylist(molioMusic: MolioMusic) 3 | } 4 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/FriendPlaylistDetailView/Delegate/FriendPlaylistDetailHostingViewController+ExportFriendsMusicToMyPlaylistDelegate.swift: -------------------------------------------------------------------------------- 1 | extension FriendPlaylistDetailHostingViewController: ExportFriendsMusicToMyPlaylistDelegate { 2 | func exportFriendsMusicToMyPlaylist(molioMusic: MolioMusic) { 3 | // 친구의 음악을 내 플레이리스트에 추가하는 시트 생성 4 | let viewModel = SelectPlaylistToExportFriendMusicViewModel(selectedMusic: molioMusic) 5 | let selectPlaylistView = SelectPlaylistToExportFriendMusicView(viewModel: viewModel) 6 | self.presentCustomSheet(content: selectPlaylistView) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/FriendPlaylistDetailView/View/FriendPlaylistDetailHostingViewController.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | final class FriendPlaylistDetailHostingViewController: UIHostingController { 5 | // MARK: - Initializer 6 | 7 | init( 8 | playlist: MolioPlaylist, 9 | fetchPlaylistUseCase: FetchPlaylistUseCase = DIContainer.shared.resolve() 10 | ) { 11 | let viewModel = FriendPlaylistDetailViewModel( 12 | friendPlaylist: playlist, 13 | fetchPlaylistUseCase: fetchPlaylistUseCase 14 | ) 15 | 16 | let rootView = FriendPlaylistDetailView( 17 | viewModel: viewModel 18 | ) 19 | 20 | super.init(rootView: rootView) 21 | 22 | viewModel.setDelegate(self) 23 | } 24 | 25 | required init?(coder: NSCoder) { 26 | super.init(coder: coder) 27 | } 28 | 29 | // MARK: - Life Cycle 30 | 31 | override func viewWillAppear(_ animated: Bool) { 32 | super.viewWillAppear(animated) 33 | navigationController?.setNavigationBarHidden(false, animated: animated) 34 | } 35 | 36 | override func viewWillDisappear(_ animated: Bool) { 37 | super.viewWillDisappear(animated) 38 | navigationController?.setNavigationBarHidden(true, animated: animated) 39 | } 40 | } 41 | 42 | @available(iOS 17, *) 43 | #Preview { 44 | UINavigationController(rootViewController: FriendPlaylistDetailHostingViewController(playlist: .mock2)) 45 | } 46 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/FriendPlaylistDetailView/ViewModel/FriendPlaylistDetailViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | final class FriendPlaylistDetailViewModel: ObservableObject { 5 | @Published var friendPlaylist: MolioPlaylist? 6 | @Published var friendPlaylistMusics: [MolioMusic] = [] 7 | @Published var selectedIndex: Int? 8 | 9 | private let fetchPlaylistUseCase: FetchPlaylistUseCase 10 | var exportFriendsMusicToMyPlaylistDelegate: ExportFriendsMusicToMyPlaylistDelegate? 11 | 12 | init( 13 | friendPlaylist: MolioPlaylist, 14 | fetchPlaylistUseCase: FetchPlaylistUseCase = DIContainer.shared.resolve() 15 | ) { 16 | self.friendPlaylist = friendPlaylist 17 | self.fetchPlaylistUseCase = fetchPlaylistUseCase 18 | self.fetchMusics(for: friendPlaylist) 19 | } 20 | 21 | private func fetchMusics(for playlist: MolioPlaylist) { 22 | Task { @MainActor [weak self] in 23 | guard let self = self else { return } 24 | do { 25 | // MARK: 친구 아이디가 아닌 경우에도 필요하게 되었다. 임시로 ""로 처리한다. 없어도 된다 26 | 27 | // TODO: - 배포 때는 이 줄 바꾸기 28 | self.friendPlaylistMusics = try await self.fetchPlaylistUseCase 29 | .fetchAllFriendMusics( 30 | friendUserID: "", 31 | playlistID: playlist.id 32 | ) 33 | debugPrint(self.friendPlaylistMusics) 34 | } catch { 35 | debugPrint(error) 36 | } 37 | } 38 | } 39 | 40 | func setDelegate(_ delegate: ExportFriendsMusicToMyPlaylistDelegate) { 41 | self.exportFriendsMusicToMyPlaylistDelegate = delegate 42 | } 43 | 44 | func exportFriendsMusicToMyPlaylist(molioMusic: MolioMusic) { 45 | exportFriendsMusicToMyPlaylistDelegate?.exportFriendsMusicToMyPlaylist(molioMusic: molioMusic) 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/FriendPlaylistDetailView/ViewModel/SelectPlaylistToExportFriendMusicViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | final class SelectPlaylistToExportFriendMusicViewModel: ObservableObject { 5 | @Published var playlists: [MolioPlaylist] = [] 6 | @Published var selectedPlaylist: MolioPlaylist? 7 | 8 | let selectedMusic: MolioMusic 9 | 10 | private let fetchPlaylistUseCase: FetchPlaylistUseCase 11 | private let manageMyPlaylistUseCase: ManageMyPlaylistUseCase 12 | 13 | init( 14 | fetchPlaylistUseCase: FetchPlaylistUseCase = DIContainer.shared.resolve(), 15 | manageMyPlaylistUseCase: ManageMyPlaylistUseCase = DIContainer.shared.resolve(), 16 | selectedMusic: MolioMusic 17 | ) { 18 | self.fetchPlaylistUseCase = fetchPlaylistUseCase 19 | self.manageMyPlaylistUseCase = manageMyPlaylistUseCase 20 | self.selectedMusic = selectedMusic 21 | fetchPlaylists() 22 | } 23 | 24 | func fetchPlaylists() { 25 | Task { @MainActor [weak self] in 26 | guard let self else { return } 27 | do { 28 | self.playlists = try await self.fetchPlaylistUseCase.fetchMyAllPlaylists() 29 | } catch { 30 | print(error.localizedDescription) 31 | } 32 | } 33 | } 34 | 35 | func selectPlaylist(_ playlist: MolioPlaylist) { 36 | Task { @MainActor [weak self] in 37 | self?.selectedPlaylist = playlist 38 | } 39 | } 40 | 41 | func exportMusicToMyPlaylist (music: MolioMusic) { 42 | guard let selectedPlaylist else { return } 43 | 44 | Task { 45 | do { 46 | try await manageMyPlaylistUseCase 47 | .addMusic( 48 | musicISRC: music.isrc, 49 | to: selectedPlaylist.id 50 | ) 51 | } catch { 52 | print(error.localizedDescription) 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/ManagePlaylist/View/CreatePlaylist/CreatePlaylistViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class CreatePlaylistViewController: UIHostingController { 4 | init(viewModel: ManagePlaylistViewModel) { 5 | let view = CreatePlaylistView(viewModel: viewModel) 6 | super.init(rootView: view) 7 | 8 | rootView.dismissAction = { [weak self] in 9 | self?.dismiss(animated: true) 10 | } 11 | } 12 | 13 | required init?(coder aDecoder: NSCoder) { 14 | super.init(coder: aDecoder) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/ManagePlaylist/View/SelectPlaylist/SelectPlaylistViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class SelectPlaylistViewController: UIHostingController { 4 | weak var delegate: SelectPlaylistViewControllerDelegate? 5 | 6 | init(viewModel: ManagePlaylistViewModel, isCreatable: Bool = true) { 7 | let view = SelectPlaylistView(viewModel: viewModel, isCreatable: isCreatable) 8 | super.init(rootView: view) 9 | 10 | rootView.createButtonTapAction = { [weak self] in 11 | self?.dismiss(animated: true) { 12 | self?.delegate?.didTapCreateButton() 13 | } 14 | } 15 | } 16 | 17 | required init?(coder aDecoder: NSCoder) { 18 | super.init(coder: aDecoder) 19 | } 20 | } 21 | 22 | protocol SelectPlaylistViewControllerDelegate: AnyObject { 23 | func didTapCreateButton() 24 | } 25 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/ManagePlaylist/ViewModel/ManagePlaylistViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// CreatePlaylistView, SelectPlaylistView를 한 번에 관리하는 ViewModel 5 | final class ManagePlaylistViewModel: ObservableObject { 6 | @Published var playlists: [MolioPlaylist] = [] 7 | @Published var currentPlaylist: MolioPlaylist? 8 | 9 | private let fetchPlaylistUseCase: FetchPlaylistUseCase 10 | private let managePlaylistUseCase: ManageMyPlaylistUseCase 11 | private var cancellables = Set() 12 | 13 | init( 14 | fetchPlaylistUseCase: FetchPlaylistUseCase = DIContainer.shared.resolve(), 15 | managePlaylistUseCase: ManageMyPlaylistUseCase = DIContainer.shared.resolve() 16 | ) { 17 | self.fetchPlaylistUseCase = fetchPlaylistUseCase 18 | self.managePlaylistUseCase = managePlaylistUseCase 19 | 20 | bindPublishCurrentPlaylist() 21 | fetchPlaylists() 22 | } 23 | 24 | /// 새로운 플레이리스트를 생성하는 함수 25 | func createPlaylist(playlistName: String) async throws { 26 | try await managePlaylistUseCase.createPlaylist(playlistName: playlistName) 27 | } 28 | 29 | /// 현재 플레이리스트를 불러오는 함수 30 | func fetchPlaylists() { 31 | Task { @MainActor [weak self] in 32 | guard let playlists = try await self?.fetchPlaylistUseCase.fetchMyAllPlaylists() else { return } 33 | self?.playlists = playlists 34 | } 35 | } 36 | 37 | /// 현재 플레이리스트를 선택하는 함수 38 | func setCurrentPlaylist(_ playlist: MolioPlaylist) { 39 | Task { @MainActor in 40 | currentPlaylist = playlist 41 | changeCurrentPlaylist() 42 | } 43 | } 44 | 45 | /// 현재플레이리스트를 저장하는 함수 46 | func changeCurrentPlaylist() { 47 | guard let currentPlaylist else { return } // TODO: 현재 선택된 알람이 없음 48 | managePlaylistUseCase.changeCurrentPlaylist(playlistID: currentPlaylist.id) 49 | } 50 | 51 | // MARK: - Private Method 52 | 53 | /// 현재 선택된 플레이리스트 바인딩하는 함수 54 | private func bindPublishCurrentPlaylist() { 55 | managePlaylistUseCase.currentPlaylistPublisher() 56 | .receive(on: DispatchQueue.main) 57 | .sink { [weak self] publishedPlaylist in 58 | guard let self = self else { return } 59 | self.currentPlaylist = publishedPlaylist 60 | } 61 | .store(in: &cancellables) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/MusicFilter/View/FilterTag.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct FilterTag: View { 4 | private let content: String 5 | private let fontSize: CGFloat 6 | private let cornerRadius: CGFloat 7 | private let verticalPadding: CGFloat 8 | private let horizontalPadding: CGFloat 9 | private let isEditing: Bool 10 | private let isSelected: Bool 11 | 12 | private let tapAction: () -> Void 13 | 14 | init( 15 | content: String, 16 | fontSize: CGFloat = 14, 17 | cornerRadius: CGFloat = 8, 18 | verticalPadding: CGFloat = 6, 19 | horizontalPadding: CGFloat = 10, 20 | isEditing: Bool = false, 21 | isSelected: Bool = true, 22 | tapAction: @escaping () -> Void = {} 23 | ) { 24 | self.content = content 25 | self.fontSize = fontSize 26 | self.cornerRadius = cornerRadius 27 | self.verticalPadding = verticalPadding 28 | self.horizontalPadding = horizontalPadding 29 | self.isEditing = isEditing 30 | self.isSelected = isSelected 31 | self.tapAction = tapAction 32 | } 33 | 34 | var body: some View { 35 | HStack(spacing: 7) { 36 | Text.molioMedium(content, size: fontSize) 37 | .foregroundStyle(isSelected ? .white : .gray) 38 | if isEditing { 39 | Image.molioSemiBold(systemName: "x.circle.fill", size: 14, color: Color.background) 40 | } 41 | } 42 | .padding(.vertical, verticalPadding) 43 | .padding(.horizontal, horizontalPadding) 44 | .background { 45 | RoundedRectangle(cornerRadius: cornerRadius) 46 | .fill(isSelected ? Color.tag : .clear) 47 | } 48 | .overlay { 49 | RoundedRectangle(cornerRadius: cornerRadius) 50 | .stroke(isSelected ? .clear : .gray, lineWidth: 1) 51 | } 52 | .onTapGesture { 53 | tapAction() 54 | } 55 | } 56 | } 57 | 58 | #Preview { 59 | ZStack { 60 | Color.background 61 | VStack { 62 | FilterTag( 63 | content: "블랙핑크", 64 | isEditing: false, 65 | isSelected: true 66 | ) 67 | FilterTag( 68 | content: "블랙핑크", 69 | isEditing: true, 70 | isSelected: true 71 | ) 72 | FilterTag( 73 | content: "블랙핑크", 74 | isEditing: false, 75 | isSelected: false 76 | ) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/MyInfo/View/ChangeProfileImageView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ChangeProfileImageView: View { 4 | let selectedImageData: Data? 5 | let imageURL: URL? 6 | 7 | var body: some View { 8 | if let selectedImageData = selectedImageData, 9 | let selectedImage = UIImage(data: selectedImageData) { 10 | Image(uiImage: selectedImage) 11 | .resizable() 12 | .scaledToFill() 13 | .frame(width: 110, height: 110) 14 | .clipShape(Circle()) 15 | } else { 16 | AsyncImage(url: imageURL) { phase in 17 | if let image = phase.image { 18 | image 19 | .resizable() 20 | .scaledToFill() 21 | } else { 22 | Image(ImageResource.personCircle) 23 | .resizable() 24 | .scaledToFill() 25 | } 26 | } 27 | .frame(width: 110, height: 110) 28 | .clipShape(Circle()) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/MyInfo/View/DescriptionTextFieldView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct DescriptionTextFieldView: View { 4 | private let characterLimit: Int 5 | private var isPossibleInput: Bool 6 | @Binding var text: String 7 | 8 | init( 9 | characterLimit: Int, 10 | isPossibleInput: Bool, 11 | text: Binding 12 | ) { 13 | self.characterLimit = characterLimit 14 | self.isPossibleInput = isPossibleInput 15 | self._text = text 16 | } 17 | 18 | private var borderColor: Color { 19 | isPossibleInput ? .background : .red 20 | } 21 | 22 | private var textColor: Color { 23 | isPossibleInput ? .white : .red 24 | } 25 | 26 | var body: some View { 27 | VStack(spacing: 7) { 28 | HStack { 29 | Text("내 설명") 30 | .font(.pretendardRegular(size: 16)) 31 | .foregroundStyle(.white) 32 | Spacer() 33 | } 34 | VStack(spacing: 4) { 35 | TextField("", text: $text, axis: .vertical) 36 | .font(.pretendardMedium(size: 13)) 37 | .foregroundStyle(textColor) 38 | HStack { 39 | Spacer() 40 | Text("\(text.count)/\(characterLimit)") 41 | .font(.pretendardRegular(size: 12)) 42 | .foregroundStyle(textColor) 43 | } 44 | } 45 | .padding(EdgeInsets(top: 10, leading: 10, bottom: 4, trailing: 10)) 46 | .background( 47 | Rectangle() 48 | .foregroundStyle(.textFieldBackground) 49 | .cornerRadius(6) 50 | .overlay( 51 | RoundedRectangle(cornerRadius: 6) 52 | .stroke(borderColor, lineWidth: 1) 53 | ) 54 | ) 55 | } 56 | .padding(EdgeInsets(top: 0, leading: 22, bottom: 0, trailing: 22)) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/MyInfo/View/MyInfoViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class MyInfoViewController: UIHostingController { 4 | // MARK: - Initializer 5 | 6 | init(viewModel: MyInfoViewModel) { 7 | let myInfoView = MyInfoView(viewModel: viewModel) 8 | super.init(rootView: myInfoView) 9 | } 10 | 11 | required init?(coder aDecoder: NSCoder) { 12 | super.init(coder: aDecoder) 13 | } 14 | 15 | // MARK: - Life Cycle 16 | 17 | override func viewDidLoad() { 18 | super.viewDidLoad() 19 | navigationItem.title = "내 정보" 20 | 21 | setupButtonAction() 22 | } 23 | 24 | // MARK: - Private func 25 | 26 | private func setupButtonAction() { 27 | rootView.didTapUpdateConfirmButton = { [weak self] in 28 | self?.navigationController?.popViewController(animated: true) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/MyInfo/View/NickNameTextFieldView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct NickNameTextFieldView: View { 4 | private let characterLimit: Int 5 | private var isPossibleInput: Bool 6 | @Binding var text: String 7 | 8 | private var borderColor: Color { 9 | isPossibleInput ? .background : .red 10 | } 11 | 12 | private var textColor: Color { 13 | isPossibleInput ? .white : .red 14 | } 15 | 16 | init( 17 | characterLimit: Int, 18 | isPossibleInput: Bool, 19 | text: Binding 20 | ) { 21 | self.characterLimit = characterLimit 22 | self.isPossibleInput = isPossibleInput 23 | self._text = text 24 | } 25 | 26 | var body: some View { 27 | VStack(spacing: 7) { 28 | HStack { 29 | Text("닉네임") 30 | .font(.pretendardRegular(size: 16)) 31 | .foregroundStyle(.white) 32 | Spacer() 33 | } 34 | 35 | ZStack { 36 | Rectangle() 37 | .foregroundStyle(.textFieldBackground) 38 | .frame(height: 35) 39 | .cornerRadius(6) 40 | .overlay( 41 | RoundedRectangle(cornerRadius: 6) 42 | .stroke(borderColor, lineWidth: 1) 43 | ) 44 | HStack { 45 | TextField("", text: $text) 46 | .font(.pretendardMedium(size: 13)) 47 | .foregroundStyle(textColor) 48 | .padding(.leading, 12) 49 | Text("\(text.count)/\(characterLimit)") 50 | .font(.pretendardRegular(size: 12)) 51 | .foregroundStyle(textColor) 52 | .padding(.trailing, 12) 53 | } 54 | } 55 | } 56 | .padding(EdgeInsets(top: 0, leading: 22, bottom: 0, trailing: 22)) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/OnBoarding/OnBoardingAppleMusicAccessViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class OnBoardingAppleMusicAccessViewController: UIHostingController { 4 | // MARK: - Initializer 5 | 6 | init() { 7 | let onBoardingView = OnBoardingView(page: .seven) 8 | 9 | super.init(rootView: onBoardingView) 10 | 11 | rootView.didButtonTapped = { [weak self] in 12 | guard let self = self else { return } 13 | self.navigateToLoginViewController() 14 | setIsOnboardedTrue() 15 | } 16 | } 17 | 18 | required init?(coder aDecoder: NSCoder) { 19 | super.init(coder: aDecoder) 20 | } 21 | 22 | // MARK: - Life Cycle 23 | 24 | override func viewWillAppear(_ animated: Bool) { 25 | super.viewWillAppear(animated) 26 | navigationController?.setNavigationBarHidden(false, animated: animated) 27 | } 28 | 29 | override func viewWillDisappear(_ animated: Bool) { 30 | super.viewWillDisappear(animated) 31 | navigationController?.setNavigationBarHidden(false, animated: animated) 32 | } 33 | 34 | // MARK: - Private func 35 | 36 | private func navigateToLoginViewController() { 37 | let loginViewController = LoginViewController(viewModel: LoginViewModel()) 38 | 39 | guard let window = self.view.window else { return } 40 | 41 | UIView.transition(with: window, duration: 0.5) { 42 | loginViewController.view.alpha = 0.0 43 | window.rootViewController = loginViewController 44 | loginViewController.view.alpha = 1.0 45 | } 46 | 47 | window.makeKeyAndVisible() 48 | } 49 | 50 | private func setIsOnboardedTrue() { 51 | UserDefaults.standard.set(true, forKey: UserDefaultKey.isOnboarded.rawValue) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/OnBoarding/OnBoardingCommunityViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class OnBoardingCommunityViewController: UIHostingController { 4 | // MARK: - Initializer 5 | 6 | init() { 7 | let onBoardingView = OnBoardingView(page: .five) 8 | 9 | super.init(rootView: onBoardingView) 10 | 11 | rootView.didButtonTapped = { [weak self] in 12 | guard let self = self else { return } 13 | self.navigateToOnBoardingFriendPlaylistViewController() 14 | } 15 | } 16 | 17 | required init?(coder aDecoder: NSCoder) { 18 | super.init(coder: aDecoder) 19 | } 20 | 21 | // MARK: - Life Cycle 22 | 23 | override func viewWillAppear(_ animated: Bool) { 24 | super.viewWillAppear(animated) 25 | navigationController?.setNavigationBarHidden(false, animated: animated) 26 | } 27 | 28 | override func viewWillDisappear(_ animated: Bool) { 29 | super.viewWillDisappear(animated) 30 | navigationController?.setNavigationBarHidden(false, animated: animated) 31 | } 32 | 33 | // MARK: - Private func 34 | 35 | private func navigateToOnBoardingFriendPlaylistViewController() { 36 | let viewController = OnBoardingFriendPlaylistViewController() 37 | navigationController?.pushViewController(viewController, animated: true) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/OnBoarding/OnBoardingExportViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class OnBoardingExportViewController: UIHostingController { 4 | // MARK: - Initializer 5 | 6 | init() { 7 | let onBoardingView = OnBoardingView(page: .four) 8 | 9 | super.init(rootView: onBoardingView) 10 | 11 | rootView.didButtonTapped = { [weak self] in 12 | guard let self = self else { return } 13 | self.navigateToOnBoardingCommunityViewController() 14 | } 15 | } 16 | 17 | required init?(coder aDecoder: NSCoder) { 18 | super.init(coder: aDecoder) 19 | } 20 | 21 | // MARK: - Life Cycle 22 | 23 | override func viewWillAppear(_ animated: Bool) { 24 | super.viewWillAppear(animated) 25 | navigationController?.setNavigationBarHidden(false, animated: animated) 26 | } 27 | 28 | override func viewWillDisappear(_ animated: Bool) { 29 | super.viewWillDisappear(animated) 30 | navigationController?.setNavigationBarHidden(false, animated: animated) 31 | } 32 | 33 | // MARK: - Private func 34 | 35 | private func navigateToOnBoardingCommunityViewController() { 36 | let viewController = OnBoardingCommunityViewController() 37 | navigationController?.pushViewController(viewController, animated: true) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/OnBoarding/OnBoardingFilterViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class OnBoardingFilterViewController: UIHostingController { 4 | // MARK: - Initializer 5 | 6 | init() { 7 | let onBoardingView = OnBoardingView(page: .three) 8 | 9 | super.init(rootView: onBoardingView) 10 | 11 | rootView.didButtonTapped = { [weak self] in 12 | guard let self = self else { return } 13 | self.navigateToOnBoardingExportViewController() 14 | } 15 | } 16 | 17 | required init?(coder aDecoder: NSCoder) { 18 | super.init(coder: aDecoder) 19 | } 20 | 21 | // MARK: - Life Cycle 22 | 23 | override func viewWillAppear(_ animated: Bool) { 24 | super.viewWillAppear(animated) 25 | navigationController?.setNavigationBarHidden(false, animated: animated) 26 | } 27 | 28 | override func viewWillDisappear(_ animated: Bool) { 29 | super.viewWillDisappear(animated) 30 | navigationController?.setNavigationBarHidden(false, animated: animated) 31 | } 32 | 33 | // MARK: - Private func 34 | 35 | private func navigateToOnBoardingExportViewController() { 36 | let viewController = OnBoardingExportViewController() 37 | navigationController?.pushViewController(viewController, animated: true) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/OnBoarding/OnBoardingFriendPlaylistViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class OnBoardingFriendPlaylistViewController: UIHostingController { 4 | // MARK: - Initializer 5 | 6 | init() { 7 | let onBoardingView = OnBoardingView(page: .six) 8 | 9 | super.init(rootView: onBoardingView) 10 | 11 | rootView.didButtonTapped = { [weak self] in 12 | guard let self = self else { return } 13 | self.navigateToOnBoardingAppleMusicAccessViewController() 14 | } 15 | } 16 | 17 | required init?(coder aDecoder: NSCoder) { 18 | super.init(coder: aDecoder) 19 | } 20 | 21 | // MARK: - Life Cycle 22 | 23 | override func viewWillAppear(_ animated: Bool) { 24 | super.viewWillAppear(animated) 25 | navigationController?.setNavigationBarHidden(false, animated: animated) 26 | } 27 | 28 | override func viewWillDisappear(_ animated: Bool) { 29 | super.viewWillDisappear(animated) 30 | navigationController?.setNavigationBarHidden(false, animated: animated) 31 | } 32 | 33 | // MARK: - Private func 34 | 35 | private func navigateToOnBoardingAppleMusicAccessViewController() { 36 | let viewController = OnBoardingAppleMusicAccessViewController() 37 | navigationController?.pushViewController(viewController, animated: true) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/OnBoarding/OnBoardingPage.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum OnBoardingPage { 4 | case one 5 | case two 6 | case three 7 | case four 8 | case five 9 | case six 10 | case seven 11 | 12 | var title: String { 13 | switch self { 14 | case .one: "나만의 플레이리스트를 만들어볼까요?" 15 | case .two: "쉽고 빠르게 스와이프로\n내 취향인 노래를 저장해보세요!" 16 | case .three: "원하는 장르를 선택하여\n나만의 플레이리스트를 만들어보세요!" 17 | case .four: "몰리오에서 만들 플레이리스트를\n다른 플랫폼으로 내보낼 수 있어요!" 18 | case .five: "내 친구의 플레이리스트를\n구경할 수 있어요!" 19 | case .six: "친구의 플레이리스트에서\n마음에 드는 노래를 가져올 수 있어요!" 20 | case .seven: "Apple Music 권한을 설정하고\n몰리오를 시작해볼까요?" 21 | } 22 | } 23 | 24 | var subTitle: String? { 25 | switch self { 26 | case .one: return "몰리오는 기본 플레이리스트를 제공해요!\n내가 원하는 테마, 분위기에 따라 플리를 생성할 수 있어요." 27 | case .two: return nil 28 | case .three: return "플레이리스트에 넣을 음악의 장르를 선택할 수 있어요." 29 | case .four: return "애플 뮤직을 구독하지 않은 경우에는 플레이리스트를 사진으로 내보낼 수 있어요." 30 | case .five: return nil 31 | case .six: return nil 32 | case .seven: return nil 33 | } 34 | } 35 | 36 | var image: Image? { 37 | switch self { 38 | case .one: Image(.onboardingOne) 39 | case .two: Image(.onBoardingTwo) 40 | case .three: Image(.onBoardingThree) 41 | case .four: Image(.onboardingFour) 42 | case .five: Image(.onboardingFive) 43 | case .six: Image(.onboardingSix) 44 | case .seven: nil 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/OnBoarding/OnBoardingPlaylistViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class OnBoardingPlaylistViewController: UIHostingController { 4 | // MARK: - Initializer 5 | 6 | init() { 7 | let onBoardingView = OnBoardingView(page: .one) 8 | super.init(rootView: onBoardingView) 9 | 10 | rootView.didButtonTapped = { [weak self] in 11 | guard let self = self else { return } 12 | self.navigateToOnBoardingSwipeViewController() 13 | } 14 | } 15 | 16 | required init?(coder aDecoder: NSCoder) { 17 | super.init(coder: aDecoder) 18 | } 19 | 20 | // MARK: - Life Cycle 21 | 22 | override func viewWillAppear(_ animated: Bool) { 23 | super.viewWillAppear(animated) 24 | navigationController?.setNavigationBarHidden(false, animated: animated) 25 | } 26 | 27 | override func viewWillDisappear(_ animated: Bool) { 28 | super.viewWillDisappear(animated) 29 | navigationController?.setNavigationBarHidden(false, animated: animated) 30 | } 31 | 32 | // MARK: - Private func 33 | 34 | private func navigateToOnBoardingSwipeViewController() { 35 | let viewController = OnBoardingSwipeViewController() 36 | navigationController?.pushViewController(viewController, animated: true) 37 | } 38 | } 39 | 40 | // MARK: - Preview 41 | 42 | struct OnBoardingPlaylistViewControllerPreview: UIViewControllerRepresentable { 43 | func makeUIViewController(context: Context) -> OnBoardingPlaylistViewController { 44 | return OnBoardingPlaylistViewController() 45 | } 46 | 47 | func updateUIViewController(_ uiViewController: OnBoardingPlaylistViewController, context: Context) { } 48 | } 49 | 50 | #Preview { 51 | OnBoardingPlaylistViewControllerPreview() 52 | .edgesIgnoringSafeArea(.all) 53 | } 54 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/OnBoarding/OnBoardingSwipeViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class OnBoardingSwipeViewController: UIHostingController { 4 | // MARK: - Initializer 5 | 6 | init() { 7 | let onBoardingView = OnBoardingView(page: .two) 8 | super.init(rootView: onBoardingView) 9 | 10 | rootView.didButtonTapped = { [weak self] in 11 | guard let self = self else { return } 12 | self.navigateToOnBoardingFilterViewController() 13 | } 14 | } 15 | 16 | required init?(coder aDecoder: NSCoder) { 17 | super.init(coder: aDecoder) 18 | } 19 | 20 | // MARK: - Life Cycle 21 | 22 | override func viewWillAppear(_ animated: Bool) { 23 | super.viewWillAppear(animated) 24 | navigationController?.setNavigationBarHidden(false, animated: animated) 25 | } 26 | 27 | override func viewWillDisappear(_ animated: Bool) { 28 | super.viewWillDisappear(animated) 29 | navigationController?.setNavigationBarHidden(false, animated: animated) 30 | } 31 | 32 | // MARK: - Private func 33 | 34 | private func navigateToOnBoardingFilterViewController() { 35 | let viewController = OnBoardingFilterViewController() 36 | navigationController?.pushViewController(viewController, animated: true) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/PlaylistDetail/View/MusicCellView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct MusicCellView: View { 4 | private let music: MolioMusic 5 | 6 | init(music: MolioMusic) { 7 | self.music = music 8 | } 9 | 10 | var body: some View { 11 | HStack(spacing: 12) { 12 | AsyncImage(url: music.artworkImageURL) { phase in 13 | switch phase { 14 | case .empty: 15 | ProgressView() 16 | 17 | case .success(let image): 18 | image 19 | .resizable() 20 | .scaledToFit() 21 | 22 | case .failure: 23 | Rectangle() 24 | .fill(.tag) 25 | 26 | @unknown default: 27 | EmptyView() 28 | } 29 | } 30 | .frame(width: 50, height: 50) 31 | 32 | VStack(alignment: .leading) { 33 | Text.molioRegular(music.title, size: 17) 34 | Text.molioRegular(music.artistName, size: 13) 35 | .foregroundStyle(.secondary) 36 | } 37 | 38 | Spacer() 39 | } 40 | .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 56) 41 | .contentShape(Rectangle()) 42 | } 43 | } 44 | 45 | #Preview { 46 | MusicCellView(music: MolioMusic.apt) 47 | .background(Color.black) 48 | .foregroundStyle(.white) 49 | } 50 | #Preview { 51 | MusicCellView(music: MolioMusic.song2) 52 | .background(Color.black) 53 | .foregroundStyle(.white) 54 | } 55 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/SearchUser/View/LoginRequiredView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct LoginRequiredView: View { 4 | var didTabLoginRequiredButton: (() -> Void) 5 | 6 | var body: some View { 7 | VStack { 8 | Spacer() 9 | Spacer() 10 | Spacer() 11 | Spacer() 12 | Spacer() 13 | Spacer() 14 | Text(StringLiterals.loginRequired) 15 | .font(.pretendardSemiBold(size: 24)) 16 | .foregroundStyle(.white) 17 | .multilineTextAlignment(.center) 18 | BasicButton(type: .loginRequired) { 19 | didTabLoginRequiredButton() 20 | } 21 | .padding(.horizontal, 20) 22 | Spacer() 23 | Spacer() 24 | Spacer() 25 | Spacer() 26 | Spacer() 27 | } 28 | .frame(maxWidth: .infinity, maxHeight: .infinity) 29 | .background(.ultraThinMaterial) 30 | } 31 | } 32 | 33 | extension LoginRequiredView { 34 | enum StringLiterals { 35 | static let loginRequired: String = "로그인하고\n친구의 플레이리스트를\n구경해보세요!" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/SearchUser/View/SearchBar.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// 커스텀 검색 바 4 | /// - `searchText` : 바인딩 텍스트 5 | /// - `placeholder` : 플레이스홀더 텍스트 6 | /// - `tintColor` : 검색,취소,플레이스홀더,입력 텍스트의 색상 7 | struct SearchBar: View { 8 | @Binding var searchText: String 9 | 10 | let placeholder: String 11 | let tintColor: Color 12 | 13 | var body: some View { 14 | HStack { 15 | HStack { 16 | Image(systemName: "magnifyingglass") 17 | 18 | TextField( 19 | "검색어 입력", 20 | text: $searchText, 21 | prompt: Text(placeholder).foregroundColor(tintColor) 22 | ) 23 | 24 | if !searchText.isEmpty { 25 | Button { 26 | searchText = "" 27 | } label: { 28 | Image(systemName: "xmark.circle.fill") 29 | } 30 | } 31 | } 32 | .padding(.vertical, 7) 33 | .padding(.horizontal, 8) 34 | .foregroundColor(tintColor) // TODO: - 정확한 색상 35 | .background(.textFieldBackground) 36 | .cornerRadius(10) 37 | } 38 | .onAppear { 39 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 40 | } 41 | } 42 | } 43 | } 44 | 45 | #Preview { 46 | SearchBar( 47 | searchText: .constant("입력텍스트"), 48 | placeholder: "플레이스홀더", 49 | tintColor: .main 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/SearchUser/View/SearchUserViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class SearchUserViewController: UIHostingController { 4 | // MARK: - Initializer 5 | 6 | init() { 7 | let viewModel = SearchUserViewModel() 8 | let view = SearchUserView(viewModel: viewModel) 9 | super.init(rootView: view) 10 | 11 | rootView.didUserInfoCellTapped = { [weak self] selectedUser in 12 | guard let self else { return } 13 | self.navigateTofriendViewController(with: selectedUser) 14 | } 15 | 16 | rootView.didTabLoginRequiredButton = { 17 | if let sceneDelegate = UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate { 18 | sceneDelegate.switchToLoginViewController() 19 | } 20 | } 21 | } 22 | 23 | required init?(coder aDecoder: NSCoder) { 24 | super.init(coder: aDecoder) 25 | } 26 | 27 | // MARK: - Life Cycle 28 | override func viewWillAppear(_ animated: Bool) { 29 | super.viewWillAppear(animated) 30 | navigationController?.setNavigationBarHidden(true, animated: animated) 31 | } 32 | 33 | override func viewWillDisappear(_ animated: Bool) { 34 | super.viewWillDisappear(animated) 35 | navigationController?.setNavigationBarHidden(false, animated: animated) 36 | } 37 | 38 | // MARK: - Present Sheet or Navigation 39 | 40 | private func navigateTofriendViewController(with user: MolioFollower) { 41 | let friendProfileViewController = FriendProfileViewController( 42 | profileType: .friend( 43 | userID: user.id, 44 | isFollowing: user.followRelation 45 | ) 46 | ) 47 | navigationController?.pushViewController(friendProfileViewController, animated: true) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/Setting/View/ProfileItemView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ProfileItemView: View { 4 | @Binding var molioUser: MolioUser? 5 | 6 | var body: some View { 7 | VStack(spacing: 0) { 8 | HStack(spacing: 12) { 9 | 10 | ProfileImageView(imageURL: molioUser?.profileImageURL, size: 52) 11 | .padding(.leading, 16) 12 | .padding(.top, 6) 13 | .padding(.bottom, 6) 14 | 15 | VStack(alignment: .leading) { 16 | Text(molioUser?.name ?? "") 17 | .font(.pretendardRegular(size: 16)) 18 | .foregroundStyle(.white) 19 | .multilineTextAlignment(.leading) 20 | 21 | Text(molioUser?.description ?? "") 22 | .font(.pretendardRegular(size: 15)) 23 | .foregroundStyle(.gray) 24 | .multilineTextAlignment(.leading) 25 | } 26 | 27 | Spacer() 28 | 29 | Image(systemName: "chevron.right") 30 | .foregroundStyle(.gray) 31 | .padding(.trailing, 16) 32 | } 33 | .padding(.vertical, 8) 34 | } 35 | .background(Color(uiColor: UIColor(resource: .background))) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/Setting/View/SettingTextItemView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SettingTextItemView: View { 4 | private let itemType: SettingItemType 5 | 6 | init(itemType: SettingItemType) { 7 | self.itemType = itemType 8 | } 9 | 10 | var body: some View { 11 | VStack(spacing: 0) { 12 | HStack { 13 | Text(itemType.text) 14 | .font(.pretendardRegular(size: 16)) 15 | .foregroundStyle(.white) 16 | .padding(.leading, 16) 17 | .padding(.top, 11) 18 | .padding(.bottom, 11) 19 | 20 | Spacer() 21 | 22 | if case let .appVersion(version) = itemType { 23 | Text(version) 24 | .font(.pretendardRegular(size: 16)) 25 | .foregroundStyle(.gray) 26 | .padding(.trailing, 16) 27 | } else { 28 | Image(systemName: "chevron.right") 29 | .foregroundColor(.gray) 30 | .padding(.trailing, 16) 31 | } 32 | } 33 | .padding(.vertical, 4) 34 | 35 | if !itemType.isLastItem { 36 | Divider() 37 | .background(Color(UIColor.darkGray)) 38 | .padding(.leading, 16) 39 | .padding(.bottom, 1) 40 | } 41 | } 42 | .background(Color(uiColor: UIColor(resource: .background))) 43 | } 44 | 45 | enum SettingItemType { 46 | case appVersion(String) 47 | case termsAndCondition 48 | case privacyPolicy 49 | case logout 50 | case login 51 | case deleteAccount 52 | 53 | var text: String { 54 | switch self { 55 | case .appVersion: 56 | "앱 버전" 57 | case .termsAndCondition: 58 | "약관 및 개인 정보 처리 동의" 59 | case .privacyPolicy: 60 | "개인정보 처리방침" 61 | case .logout: 62 | "로그 아웃" 63 | case .login: 64 | "로그인" 65 | case .deleteAccount: 66 | "회원 탈퇴" 67 | } 68 | } 69 | 70 | var isLastItem: Bool { 71 | switch self { 72 | case .appVersion, .termsAndCondition, .logout: 73 | false 74 | case .privacyPolicy, .deleteAccount, .login: 75 | true 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/SwipeMusic/Model/SwipeMusicTrackModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Swipe 할 수 있는 화면에 표시되는 음악 정보에 대한 UI View Model입니다. 4 | struct SwipeMusicTrackModel { 5 | /// 노래 제목 6 | let title: String 7 | 8 | /// 아티스트 이름 9 | let artistName: String 10 | 11 | /// 장르 목록 12 | let gerneNames: [String] 13 | 14 | /// 국제 표준 녹음 자료 코드 15 | let isrc: String 16 | 17 | /// 음악 미리듣기 URL 18 | let previewAsset: URL 19 | 20 | /// 앨범 아트워크 이미지 21 | let artworkImageData: Data? 22 | 23 | /// 앨범에 이미지에 따른 평균 배경색 24 | let artworkBackgroundColor: RGBAColor? 25 | 26 | /// 앨범에 이미지에 따른 primary 색상 27 | let primaryTextColor: RGBAColor? 28 | 29 | init(randomMusic: MolioMusic, imageData: Data?) { 30 | self.title = randomMusic.title 31 | self.artistName = randomMusic.artistName 32 | self.gerneNames = Array(randomMusic.gerneNames.prefix(3)) 33 | self.isrc = randomMusic.isrc 34 | self.previewAsset = randomMusic.previewAsset 35 | self.artworkImageData = imageData 36 | self.artworkBackgroundColor = randomMusic.artworkBackgroundColor 37 | self.primaryTextColor = randomMusic.primaryTextColor 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/SwipeMusic/ViewModel/DefaultAudioPlayer.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | 3 | final class DefaultAudioPlayer: AudioPlayer { 4 | var isPlaying: Bool = false 5 | var player: AVQueuePlayer? 6 | var looper: AVPlayerLooper? 7 | var musicItemDidPlayToEndTimeObserver: (any NSObjectProtocol)? 8 | 9 | func loadSong(with url: URL) { 10 | stop() 11 | let item = AVPlayerItem(url: url) 12 | player = AVQueuePlayer(playerItem: item) 13 | 14 | guard let player = player else { return } 15 | 16 | looper = AVPlayerLooper(player: player, templateItem: item) 17 | } 18 | 19 | func play() { 20 | guard let player = player else { return } 21 | player.play() 22 | isPlaying = true 23 | } 24 | 25 | func pause() { 26 | guard let player = player else { return } 27 | player.pause() 28 | isPlaying = false 29 | } 30 | 31 | func stop() { 32 | guard let player = player else { return } 33 | player.pause() 34 | player.seek(to: .zero) 35 | looper = nil 36 | isPlaying = false 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Molio/Source/Presentation/SwipeMusic/ViewModel/SwipeMusicPlayer.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | 3 | final class SwipeMusicPlayer: AudioPlayer { 4 | var isPlaying: Bool = false 5 | var player: AVQueuePlayer? 6 | var looper: AVPlayerLooper? 7 | 8 | func loadSong(with url: URL) { 9 | stop() 10 | let item = AVPlayerItem(url: url) 11 | player = AVQueuePlayer(playerItem: item) 12 | 13 | guard let player = player else { return } 14 | 15 | looper = AVPlayerLooper(player: player, templateItem: item) 16 | } 17 | 18 | func play() { 19 | guard let player = player else { return } 20 | player.play() 21 | isPlaying = true 22 | } 23 | 24 | func pause() { 25 | guard let player = player else { return } 26 | player.pause() 27 | isPlaying = false 28 | } 29 | 30 | func stop() { 31 | guard let player = player else { return } 32 | player.pause() 33 | player.seek(to: .zero) 34 | looper = nil 35 | isPlaying = false 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /Molio/Source/Util/Extension/Foundation/String+Extension.swift: -------------------------------------------------------------------------------- 1 | extension String { 2 | var toBase64: String? { 3 | self.data(using: .utf8)?.base64EncodedString() 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /Molio/Source/Util/Extension/SwiftUI/Font+Extension.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // 프리텐다드 폰트 4 | /// 폰트 그대로 사용하지 마세요. Text+Extension을 참고하세요. 5 | extension Font { 6 | static func pretendardBlack(size: CGFloat) -> Font { 7 | .custom(PretendardFontName.Black, size: size) 8 | } 9 | static func pretendardBold(size: CGFloat) -> Font { 10 | .custom(PretendardFontName.Bold, size: size) 11 | } 12 | static func pretendardExtraBold(size: CGFloat) -> Font { 13 | .custom(PretendardFontName.ExtraBold, size: size) 14 | } 15 | static func pretendardSemiBold(size: CGFloat) -> Font { 16 | .custom(PretendardFontName.SemiBold, size: size) 17 | } 18 | static func pretendardMedium(size: CGFloat) -> Font { 19 | .custom(PretendardFontName.Medium, size: size) 20 | } 21 | static func pretendardRegular(size: CGFloat) -> Font { 22 | .custom(PretendardFontName.Regular, size: size) 23 | } 24 | static func pretendardThin(size: CGFloat) -> Font { 25 | .custom(PretendardFontName.Thin, size: size) 26 | } 27 | static func pretendardLight(size: CGFloat) -> Font { 28 | .custom(PretendardFontName.Light, size: size) 29 | } 30 | static func pretendardExtraLight(size: CGFloat) -> Font { 31 | .custom(PretendardFontName.ExtraLight, size: size) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Molio/Source/Util/Extension/SwiftUI/Image+Extension.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // Pretandard 폰트와 자간 적용한 Image 4 | /// 몰리오 앱의 모든 Image는 molio<폰트이름>을 적용해서 사용해주세요. ( 사용예시는 맨 아래를 참고해주세요 ) 5 | extension Image { 6 | private static func molioFont(_ systemName: String, name: String, size: CGFloat, color: Color) -> some View { 7 | Image(systemName: systemName) 8 | .font(.custom(name, size: size)) 9 | .foregroundStyle(color) 10 | } 11 | 12 | static func molioBlack(systemName: String, size: CGFloat, color: Color) -> some View { 13 | molioFont(systemName, name: PretendardFontName.Black, size: size, color: color) 14 | } 15 | 16 | static func molioBold(systemName: String, size: CGFloat, color: Color) -> some View { 17 | molioFont(systemName, name: PretendardFontName.Bold, size: size, color: color) 18 | } 19 | 20 | static func molioExtraBold(systemName: String, size: CGFloat, color: Color) -> some View { 21 | molioFont(systemName, name: PretendardFontName.ExtraBold, size: size, color: color) 22 | } 23 | 24 | static func molioSemiBold(systemName: String, size: CGFloat, color: Color) -> some View { 25 | molioFont(systemName, name: PretendardFontName.SemiBold, size: size, color: color) 26 | } 27 | 28 | static func molioMedium(systemName: String, size: CGFloat, color: Color) -> some View { 29 | molioFont(systemName, name: PretendardFontName.Medium, size: size, color: color) 30 | } 31 | 32 | static func molioRegular(systemName: String, size: CGFloat, color: Color) -> some View { 33 | molioFont(systemName, name: PretendardFontName.Regular, size: size, color: color) 34 | } 35 | 36 | static func molioThin(systemName: String, size: CGFloat, color: Color) -> some View { 37 | molioFont(systemName, name: PretendardFontName.Thin, size: size, color: color) 38 | } 39 | 40 | static func molioLight(systemName: String, size: CGFloat, color: Color) -> some View { 41 | molioFont(systemName, name: PretendardFontName.Light, size: size, color: color) 42 | } 43 | 44 | static func molioExtraLight(systemName: String, size: CGFloat, color: Color) -> some View { 45 | molioFont(systemName, name: PretendardFontName.ExtraLight, size: size, color: color) 46 | } 47 | } 48 | 49 | // 사용 예시 50 | // Image.molioBlack("Black Font", size: 20) 51 | -------------------------------------------------------------------------------- /Molio/Source/Util/Extension/SwiftUI/Text+Extension.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // Pretandard 폰트와 자간 적용한 Text 4 | /// 몰리오 앱의 모든 Text는 molio<폰트이름>을 적용해서 사용해주세요. ( 사용예시는 맨 아래를 참고해주세요 ) 5 | extension Text { 6 | static let spacing: CGFloat = -0.5 7 | 8 | private static func molioFont(_ content: String, name: String, size: CGFloat) -> Text { 9 | Text(content) 10 | .font(.custom(name, size: size)) 11 | .tracking(spacing) 12 | } 13 | 14 | static func molioBlack(_ content: String, size: CGFloat) -> Text { 15 | molioFont(content, name: "Pretendard-Black", size: size) 16 | } 17 | 18 | static func molioBold(_ content: String, size: CGFloat) -> Text { 19 | molioFont(content, name: "Pretendard-Bold", size: size) 20 | } 21 | 22 | static func molioExtraBold(_ content: String, size: CGFloat) -> Text { 23 | molioFont(content, name: "Pretendard-ExtraBold", size: size) 24 | } 25 | 26 | static func molioSemiBold(_ content: String, size: CGFloat) -> Text { 27 | molioFont(content, name: "Pretendard-SemiBold", size: size) 28 | } 29 | 30 | static func molioMedium(_ content: String, size: CGFloat) -> Text { 31 | molioFont(content, name: "Pretendard-Medium", size: size) 32 | } 33 | 34 | static func molioRegular(_ content: String, size: CGFloat) -> Text { 35 | molioFont(content, name: "Pretendard-Regular", size: size) 36 | } 37 | 38 | static func molioThin(_ content: String, size: CGFloat) -> Text { 39 | molioFont(content, name: "Pretendard-Thin", size: size) 40 | } 41 | 42 | static func molioLight(_ content: String, size: CGFloat) -> Text { 43 | molioFont(content, name: "Pretendard-Light", size: size) 44 | } 45 | 46 | static func molioExtraLight(_ content: String, size: CGFloat) -> Text { 47 | molioFont(content, name: "Pretendard-ExtraLight", size: size) 48 | } 49 | } 50 | 51 | // 사용 예시 52 | // Text.molioBlack("Black Font", size: 20) 53 | -------------------------------------------------------------------------------- /Molio/Source/Util/Extension/SwiftUI/View+Extension.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | func hideKeyboard() { 5 | UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Molio/Source/Util/Extension/UIKit/UIColor+Extension.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIColor { 4 | 5 | convenience init?(hex: String) { 6 | let red, green, blue: CGFloat 7 | 8 | let start = hex.hasPrefix("#") ? hex.index(hex.startIndex, offsetBy: 1) : hex.startIndex 9 | let hexColor = String(hex[start...]) 10 | 11 | if hexColor.count == 6 { 12 | let scanner = Scanner(string: hexColor) 13 | var hexNumber: UInt64 = 0 14 | 15 | if scanner.scanHexInt64(&hexNumber) { 16 | red = CGFloat((hexNumber & 0xFF0000) >> 16) / 255.0 17 | green = CGFloat((hexNumber & 0x00FF00) >> 8) / 255.0 18 | blue = CGFloat(hexNumber & 0x0000FF) / 255.0 19 | 20 | self.init(red: red, green: green, blue: blue, alpha: 1.0) 21 | return 22 | } 23 | } 24 | 25 | return nil 26 | } 27 | 28 | convenience init(rgbaColor: RGBAColor) { 29 | self.init(red: rgbaColor.red, 30 | green: rgbaColor.green, 31 | blue: rgbaColor.blue, 32 | alpha: rgbaColor.alpha 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Molio/Source/Util/Extension/UIKit/UILabel+Extension.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | // Pretendard 폰트와 자간 적용한 UILabel 4 | /// 몰리오 앱의 모든 UILabel는 molio<폰트이름>을 적용해서 사용해주세요. ( 사용예시는 맨 아래를 참고해주세요 ) 5 | extension UILabel { 6 | static let letterSpacing: CGFloat = -0.5 7 | 8 | private func applyFont(name: String, size: CGFloat, text: String) { 9 | let font = UIFont(name: name, size: size) ?? UIFont.systemFont(ofSize: size) 10 | let attributedString = NSMutableAttributedString(string: text) 11 | attributedString.addAttributes([ 12 | .font: font, 13 | .kern: UILabel.letterSpacing 14 | ], range: NSRange(location: 0, length: attributedString.length)) 15 | self.attributedText = attributedString 16 | } 17 | 18 | func molioBlack(text: String, size: CGFloat) { 19 | applyFont(name: PretendardFontName.Black, size: size, text: text) 20 | } 21 | 22 | func molioBold(text: String, size: CGFloat) { 23 | applyFont(name: PretendardFontName.Bold, size: size, text: text) 24 | } 25 | 26 | func molioExtraBold(text: String, size: CGFloat) { 27 | applyFont(name: PretendardFontName.ExtraBold, size: size, text: text) 28 | } 29 | 30 | func molioSemiBold(text: String, size: CGFloat) { 31 | applyFont(name: PretendardFontName.SemiBold, size: size, text: text) 32 | } 33 | 34 | func molioMedium(text: String, size: CGFloat) { 35 | applyFont(name: PretendardFontName.Medium, size: size, text: text) 36 | } 37 | 38 | func molioRegular(text: String, size: CGFloat) { 39 | applyFont(name: PretendardFontName.Regular, size: size, text: text) 40 | } 41 | 42 | func molioThin(text: String, size: CGFloat) { 43 | applyFont(name: PretendardFontName.Thin, size: size, text: text) 44 | } 45 | 46 | func molioLight(text: String, size: CGFloat) { 47 | applyFont(name: PretendardFontName.Light, size: size, text: text) 48 | } 49 | 50 | func molioExtraLight(text: String, size: CGFloat) { 51 | applyFont(name: PretendardFontName.ExtraLight, size: size, text: text) 52 | } 53 | } 54 | 55 | // 사용 예시 56 | // let label = UILabel() 57 | // label.molioBold(text: "Bold Font", size: 20) 58 | -------------------------------------------------------------------------------- /Molio/Source/Util/Extension/UIKit/UIViewController/showAlert.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIViewController { 4 | /// 확인 버튼 1개만 존재하는 기본 알럿 띄우기 5 | func showAlertWithOKButton( 6 | title: String, 7 | message: String? = nil, 8 | buttonLabel: String = "확인", 9 | onCompletion: ((UIAlertAction) -> Void)? = nil 10 | ) { 11 | let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) 12 | 13 | let action = UIAlertAction(title: buttonLabel, style: .default, handler: onCompletion) 14 | alert.addAction(action) 15 | self.present(alert, animated: true) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Molio/Source/Util/Extension/UIKit/addGradientBackground.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIView { 4 | func addGradientBackground( 5 | gradientColors: [UIColor] = [ 6 | UIColor(red: 0.22, green: 0.22, blue: 0.22, alpha: 0.23), 7 | UIColor(red: 0, green: 0, blue: 0, alpha: 0.30), 8 | UIColor(red: 0.22, green: 0.22, blue: 0.22, alpha: 0.25) 9 | ], 10 | gradientStartPoint: CGPoint = CGPoint(x: 0, y: 0), 11 | gradientEndPoint: CGPoint = CGPoint(x: 1, y: 0), 12 | blurStyle: UIBlurEffect.Style = .regular 13 | ) { 14 | // self.subviews.forEach { $0.removeFromSuperview() } 15 | 16 | // 그라데이션 레이어 추가 17 | let gradientLayer = CAGradientLayer() 18 | gradientLayer.colors = gradientColors.map { $0.cgColor } 19 | gradientLayer.startPoint = gradientStartPoint 20 | gradientLayer.endPoint = gradientEndPoint 21 | gradientLayer.frame = bounds 22 | layer.addSublayer(gradientLayer) 23 | 24 | // 블러 효과 추가 25 | let blurEffect = UIBlurEffect(style: blurStyle) 26 | let blurView = UIVisualEffectView(effect: blurEffect) 27 | blurView.frame = bounds 28 | blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight] 29 | addSubview(blurView) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Molio/Source/Util/Mapper/CGColorMapper.swift: -------------------------------------------------------------------------------- 1 | import CoreGraphics 2 | 3 | struct CGColorMapper { 4 | static func toDomain(_ cgColor: CGColor) -> RGBAColor? { 5 | guard let components = cgColor.components, 6 | components.count >= 4, 7 | cgColor.colorSpace?.model == .rgb else { 8 | return nil 9 | } 10 | 11 | return RGBAColor( 12 | red: components[0], 13 | green: components[1], 14 | blue: components[2], 15 | alpha: components[3] 16 | ) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Molio/Source/Util/Mapper/MolioPlaylistMapper.swift: -------------------------------------------------------------------------------- 1 | import FirebaseCore 2 | 3 | struct MolioPlaylistMapper: BidirectionalMapper { 4 | typealias Entity = MolioPlaylist 5 | typealias DTO = MolioPlaylistDTO 6 | 7 | private init() {} 8 | 9 | static func map(from entity: MolioPlaylist) -> MolioPlaylistDTO { 10 | return MolioPlaylistDTO( 11 | id: entity.id.uuidString, 12 | authorID: entity.authorID, 13 | title: entity.name, 14 | createdAt: Timestamp(date: entity.createdAt), 15 | filters: entity.filter, 16 | musicISRCs: entity.musicISRCs, 17 | likes: entity.like 18 | ) 19 | } 20 | 21 | static func map(from dto: MolioPlaylistDTO) -> MolioPlaylist { 22 | return MolioPlaylist( 23 | // TODO: dto.id가 UUID가 아닐리가 없긴 하지만 나중에 수정하기 24 | id: UUID(uuidString: dto.id) ?? UUID(), 25 | authorID: dto.authorID, 26 | name: dto.title, 27 | createdAt: dto.createdAt.dateValue(), 28 | musicISRCs: dto.musicISRCs, 29 | filter: dto.filters, 30 | like: dto.likes 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Molio/Source/Util/Protocol/AudioPlayer.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | 3 | protocol AudioPlayer { 4 | var isPlaying: Bool { get } 5 | var musicItemDidPlayToEndTimeObserver: NSObjectProtocol? { get nonmutating set } 6 | func loadSong(with url: URL) 7 | func play() 8 | func pause() 9 | func stop() 10 | } 11 | -------------------------------------------------------------------------------- /Molio/Source/Util/Protocol/BidirectionalMapper.swift: -------------------------------------------------------------------------------- 1 | protocol BidirectionalMapper { 2 | associatedtype Entity 3 | associatedtype DTO 4 | 5 | static func map(from: Entity) -> DTO 6 | static func map(from: DTO) -> Entity 7 | } 8 | -------------------------------------------------------------------------------- /MolioTests/FirebaseStorageManagerTests.swift: -------------------------------------------------------------------------------- 1 | //import XCTest 2 | //import FirebaseStorage 3 | //@testable import Molio 4 | // 5 | //final class FirebaseStorageManagerTests: XCTestCase { 6 | // var manager: FirebaseStorageManager! 7 | // let testImageData: Data = (UIImage(named: "AlbumCoverSample")?.jpegData(compressionQuality: 0.8)!)! // 테스트용 이미지 8 | // let testFolder = "test_images" 9 | // 10 | // override func setUp() { 11 | // super.setUp() 12 | // manager = FirebaseStorageManager() 13 | // } 14 | // 15 | // override func tearDown() { 16 | // manager = nil 17 | // super.tearDown() 18 | // } 19 | // 20 | // func testUploadImageSuccess() { 21 | // let expectation = self.expectation(description: "Image upload succeeds") 22 | // 23 | // manager.uploadImage(imageData: testImageData, folder: testFolder) { result in 24 | // switch result { 25 | // case .success(let downloadURL): 26 | // XCTAssertNotNil(downloadURL, "Download URL should not be nil") 27 | // XCTAssertTrue(downloadURL.contains("https://"), "Download URL should be valid") 28 | // case .failure(let error): 29 | // XCTFail("Image upload failed with error: \(error)") 30 | // } 31 | // expectation.fulfill() 32 | // } 33 | // 34 | // waitForExpectations(timeout: 10, handler: nil) 35 | // } 36 | // 37 | // func testUploadImageFailure() { 38 | // let invalidImageData = Data()// Empty UIImage, invalid for uploading 39 | // let expectation = self.expectation(description: "Image upload fails due to invalid data") 40 | // 41 | // manager.uploadImage(imageData: invalidImageData, folder: testFolder) { result in 42 | // switch result { 43 | // case .success(let downloadURL): 44 | // XCTFail("Upload should not succeed with invalid data, but returned URL: \(downloadURL)") 45 | // case .failure(let error): 46 | // XCTAssertEqual(error as? FirebaseStorageError, FirebaseStorageError.invalidImageData) 47 | // } 48 | // expectation.fulfill() 49 | // } 50 | // 51 | // waitForExpectations(timeout: 10, handler: nil) 52 | // } 53 | //} 54 | -------------------------------------------------------------------------------- /MolioTests/Test Double/Dummy/SpotifyAccessTokenResponseDTODummy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import Molio 3 | 4 | extension SpotifyAccessTokenResponseDTO: Swift.Equatable { 5 | public static func == (lhs: SpotifyAccessTokenResponseDTO, rhs: SpotifyAccessTokenResponseDTO) -> Bool { 6 | let isAccessTokenEqual = lhs.accessToken == rhs.accessToken 7 | let isTokenTypeEqual = lhs.tokenType == rhs.tokenType 8 | let isExpiredInEqual = lhs.expiresIn == rhs.expiresIn 9 | 10 | return isAccessTokenEqual && isTokenTypeEqual && isExpiredInEqual 11 | } 12 | } 13 | 14 | extension SpotifyAccessTokenResponseDTO { 15 | static let dummyData: Data = 16 | #""" 17 | { 18 | "access_token": "BQB6u7vBjWYPp3zHwRwV9Nmb9MC280UBk2poWgeSuOYmScPmlMiEeENy3oG6sB2Dy2_jsPeSjmhI5fnKXe5EMoc0XLNQ_2cyIpH_GnLEdadMroAIw2c", 19 | "token_type": "Bearer", 20 | "expires_in": 3600 21 | } 22 | """#.data(using: .utf8)! 23 | } 24 | -------------------------------------------------------------------------------- /MolioTests/Test Double/Mock/DataSource/MockAuthService.swift: -------------------------------------------------------------------------------- 1 | import FirebaseAuth 2 | 3 | @testable import Molio 4 | 5 | final class MockAuthService: AuthService { 6 | var currentUserID: String = "myUserID" 7 | 8 | func getCurrentUser() async throws -> FirebaseAuth.User { 9 | throw FirestoreError.documentFetchError 10 | } 11 | 12 | func getCurrentID() -> String { 13 | return currentUserID 14 | 15 | } 16 | 17 | func signInApple(info: AppleAuthInfo) async throws -> (uid: String, isNewUser: Bool) { 18 | return ("", false) 19 | } 20 | 21 | func logout() throws { 22 | 23 | } 24 | func reauthenticateApple(idToken: String, nonce: String) async throws { 25 | 26 | } 27 | 28 | func deleteAccount(authorizationCode: String) async throws { 29 | 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /MolioTests/Test Double/Mock/DataSource/MockFollowRelationService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import Molio 3 | 4 | final class MockFollowRelationService: FollowRelationService { 5 | var createdRelations: [FollowRelationDTO] = [] 6 | var updatedRelations: [String: Bool] = [:] 7 | var deletedRelations: [String] = [] 8 | var fetchedRelations: [FollowRelationDTO] = [] 9 | 10 | func createFollowRelation(from followerID: String, to followingID: String) async throws { 11 | let relation = FollowRelationDTO( 12 | id: UUID().uuidString, 13 | date: Date(), 14 | following: followingID, 15 | follower: followerID, 16 | state: false 17 | ) 18 | createdRelations.append(relation) 19 | } 20 | 21 | func readFollowRelation(followingID: String?, followerID: String?, state: Bool?) async throws -> [FollowRelationDTO] { 22 | return fetchedRelations.filter { relation in 23 | (followingID == nil || relation.following == followingID) && 24 | (followerID == nil || relation.follower == followerID) && 25 | (state == nil || relation.state == state) 26 | } 27 | } 28 | 29 | func updateFollowRelation(relationID: String, state: Bool) async throws { 30 | updatedRelations[relationID] = state 31 | } 32 | 33 | func deleteFollowRelation(relationID: String) async throws { 34 | deletedRelations.append(relationID) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /MolioTests/Test Double/Mock/DataSource/MockNetworkProvider.swift: -------------------------------------------------------------------------------- 1 | @testable import Molio 2 | 3 | final class MockNetworkProvider: NetworkProvider { 4 | var isErrorThrow: Bool = false 5 | var dtoToReturn: Decodable? 6 | var errorToThrow: Error? 7 | var isRequestCalled: Bool = false 8 | var requestCallCount: Int = 0 9 | 10 | func request(_ endPoint: any Molio.EndPoint) async throws -> T { 11 | requestCallCount += 1 12 | isRequestCalled = true 13 | if isErrorThrow { 14 | throw errorToThrow! 15 | } else { 16 | return dtoToReturn as! T 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /MolioTests/Test Double/Mock/DataSource/MockPlaylistService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import Molio 3 | 4 | actor MockPlaylistService: PlaylistService { 5 | private var playlists: [String: MolioPlaylistDTO] = [:] 6 | 7 | func createPlaylist(playlist: MolioPlaylistDTO) async throws { 8 | if playlists[playlist.id] != nil { 9 | throw MockFirestoreError.playlistAlreadyExists 10 | } 11 | playlists[playlist.id] = playlist 12 | } 13 | 14 | func readPlaylist(playlistID: UUID) async throws -> MolioPlaylistDTO { 15 | guard let playlist = playlists[playlistID.uuidString] else { 16 | throw MockFirestoreError.playlistNotFound 17 | } 18 | return playlist 19 | } 20 | 21 | func readAllPlaylist(userID: String) async throws -> [MolioPlaylistDTO] { 22 | return playlists.values.filter { $0.authorID == userID } 23 | } 24 | 25 | func updatePlaylist(newPlaylist: MolioPlaylistDTO) async throws { 26 | guard playlists[newPlaylist.id] != nil else { 27 | throw MockFirestoreError.playlistNotFound 28 | } 29 | playlists[newPlaylist.id] = newPlaylist 30 | } 31 | 32 | func deletePlaylist(playlistID: UUID) async throws { 33 | guard playlists.removeValue(forKey: playlistID.uuidString) != nil else { 34 | throw MockFirestoreError.playlistNotFound 35 | } 36 | } 37 | } 38 | 39 | enum MockFirestoreError: Error { 40 | case playlistAlreadyExists 41 | case playlistNotFound 42 | case failedToConvertToDictionary 43 | } 44 | -------------------------------------------------------------------------------- /MolioTests/Test Double/Mock/DataSource/MockURLProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | final class MockURLProtocol: URLProtocol { 5 | static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? 6 | 7 | override class func canInit(with request: URLRequest) -> Bool { 8 | true 9 | } 10 | 11 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 12 | request 13 | } 14 | 15 | override func startLoading() { 16 | guard let handler = MockURLProtocol.requestHandler else { 17 | XCTFail("requestHandler를 지정하지 않았습니다.") 18 | return 19 | } 20 | do { 21 | let (response, data) = try handler(request) 22 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) 23 | client?.urlProtocol(self, didLoad: data) 24 | client?.urlProtocolDidFinishLoading(self) 25 | } catch { 26 | client?.urlProtocol(self, didFailWithError: error) 27 | } 28 | } 29 | 30 | override func stopLoading() {} 31 | } 32 | -------------------------------------------------------------------------------- /MolioTests/Test Double/Mock/Repository/MockCurrentPlaylistRepository.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | @testable import Molio 4 | 5 | final class MockCurrentPlaylistRepository: CurrentPlaylistRepository { 6 | func setDefaultPlaylist(_ id: UUID) throws {} 7 | 8 | private var currentPlaylistUUID = CurrentValueSubject(nil) 9 | 10 | var currentPlaylistPublisher: AnyPublisher { 11 | currentPlaylistUUID.eraseToAnyPublisher() 12 | } 13 | 14 | func setCurrentPlaylist(_ id: UUID) { 15 | currentPlaylistUUID.send(id) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /MolioTests/Test Double/Mock/Repository/MockPlaylistRepository.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | @testable import Molio 4 | 5 | final class MockPlaylistRepository: PlaylistRepository { 6 | var createdPlaylistName: String? 7 | var createdUserID: String? 8 | var mockPlaylist: MolioPlaylist? 9 | var updatedPlaylist: MolioPlaylist? 10 | var deletedPlaylistID: UUID? 11 | var playlistsToFetch: [MolioPlaylist]? 12 | 13 | var addMusicCalled = false 14 | var deleteMusicCalled = false 15 | var moveMusicCalled = false 16 | var createNewPlaylistCalled = false 17 | var deletePlaylistCalled = false 18 | var fetchPlaylistsCalled = false 19 | var fetchPlaylistCalled = false 20 | var updatePlaylistCalled = false 21 | 22 | func addMusic(userID: String?, isrc: String, to playlistID: UUID) async throws { 23 | addMusicCalled = true 24 | } 25 | 26 | func deleteMusic(userID: String?, isrc: String, in playlistID: UUID) async throws { 27 | deleteMusicCalled = true 28 | } 29 | 30 | func moveMusic(userID: String?, isrc: String, in playlistID: UUID, fromIndex: Int, toIndex: Int) async throws { 31 | moveMusicCalled = true 32 | } 33 | 34 | func createNewPlaylist(userID: String?, playlistID: UUID, _ playlistName: String) async throws { 35 | createNewPlaylistCalled = true 36 | createdUserID = userID 37 | createdPlaylistName = playlistName 38 | } 39 | 40 | func deletePlaylist(userID: String?, _ playlistID: UUID) async throws { 41 | deletePlaylistCalled = true 42 | deletedPlaylistID = playlistID 43 | } 44 | 45 | func fetchPlaylists(userID: String?) async throws -> [MolioPlaylist]? { 46 | fetchPlaylistsCalled = true 47 | return playlistsToFetch 48 | } 49 | 50 | func fetchPlaylist(userID: String?, for playlistID: UUID) async throws -> MolioPlaylist? { 51 | fetchPlaylistCalled = true 52 | return mockPlaylist 53 | } 54 | 55 | func updatePlaylist(userID: String?, newPlaylist: MolioPlaylist) async throws { 56 | updatePlaylistCalled = true 57 | updatedPlaylist = newPlaylist 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /MolioTests/Test Double/Mock/UseCase/MockCurrentUserIdUseCase.swift: -------------------------------------------------------------------------------- 1 | @testable import Molio 2 | 3 | final class MockCurrentUserIdUseCase: CurrentUserIdUseCase { 4 | var userIDToReturn: String? 5 | func execute() throws -> String? { 6 | userIDToReturn 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /MolioTests/Test Double/Mock/UseCase/MockManagePlaylistUseCase.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | @testable import Molio 4 | 5 | struct MockManagePlaylistUseCase: ManageMyPlaylistUseCase { 6 | var playlistToReturn: MolioPlaylist? 7 | 8 | func currentPlaylistPublisher() -> AnyPublisher { 9 | return Just(playlistToReturn).eraseToAnyPublisher() 10 | } 11 | 12 | func changeCurrentPlaylist(playlistID: UUID) {} 13 | 14 | func createPlaylist(playlistName: String) async throws {} 15 | 16 | func updatePlaylistName(playlistID: UUID, name: String) async throws {} 17 | 18 | func updatePlaylistFilter(playlistID: UUID, filter: [MusicGenre]) async throws {} 19 | 20 | func deletePlaylist(playlistID: UUID) async throws {} 21 | 22 | func addMusic(musicISRC: String, to playlistID: UUID) async throws {} 23 | 24 | func deleteMusic(musicISRC: String, from playlistID: UUID) async throws {} 25 | 26 | func moveMusic(musicISRC: String, in playlistID: UUID, fromIndex: Int, toIndex: Int) async throws {} 27 | } 28 | -------------------------------------------------------------------------------- /scripts/run_build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | SCHEME='Molio' 3 | DESTINATION='platform=iOS Simulator,OS=17.0.1,name=iPhone 15 Pro' 4 | # DESTINATION='platform=iOS Simulator,OS=18.0,name=iPhone 16 Pro' # 로컬용 5 | set -o pipefail && xcodebuild clean build \ 6 | -scheme $SCHEME \ 7 | -sdk iphonesimulator \ 8 | -destination "$DESTINATION" \ 9 | -skipPackagePluginValidation \ 10 | CODE_SIGNING_ALLOWED='NO' | xcpretty --simple --color 11 | -------------------------------------------------------------------------------- /scripts/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | SCHEME='Molio' 3 | DESTINATION='platform=iOS Simulator,OS=17.0.1,name=iPhone 15 Pro' 4 | # DESTINATION='platform=iOS Simulator,OS=18.0,name=iPhone 16 Pro' # 로컬용 5 | set -o pipefail && xcodebuild clean test \ 6 | -scheme $SCHEME \ 7 | -sdk iphonesimulator \ 8 | -destination "$DESTINATION" \ 9 | -skipPackagePluginValidation \ 10 | CODE_SIGNING_ALLOWED='NO' | xcpretty --simple --color 11 | --------------------------------------------------------------------------------