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