├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md └── pull_request_template.md ├── .gitignore ├── README.md └── SniffMeet ├── SNMNetworkTests ├── AnyEncodableTests.swift ├── EndpointTests.swift ├── MultipartFormDataTests.swift └── SNMNetworkTests.swift ├── SNMPersistenceTests ├── FileManagerTest.swift ├── KeychainTests.swift └── UserDefaultsTests.swift ├── SniffMeet.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── SNMNetworkTests.xcscheme │ ├── SNMPersistenceTests.xcscheme │ └── SniffMeet.xcscheme ├── SniffMeet ├── .swiftlint.yml ├── Resource │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── 1024.png │ │ │ └── Contents.json │ │ ├── AppImage.imageset │ │ │ ├── Contents.json │ │ │ ├── Frame 121.svg │ │ │ ├── Frame 122.svg │ │ │ └── Frame 123.svg │ │ ├── BrownPaw.imageset │ │ │ ├── Contents.json │ │ │ ├── Pet Paw(2).png │ │ │ └── Pet Paw(3).png │ │ ├── Contents.json │ │ ├── ImagePlaceholder.imageset │ │ │ ├── Contents.json │ │ │ ├── image-placeholder.png │ │ │ ├── image-placeholder@2x.png │ │ │ └── image-placeholder@3x.png │ │ ├── MainColorSet │ │ │ ├── Contents.json │ │ │ ├── MainBeige.colorset │ │ │ │ └── Contents.json │ │ │ ├── MainBrown.colorset │ │ │ │ └── Contents.json │ │ │ ├── MainNavy.colorset │ │ │ │ └── Contents.json │ │ │ ├── MainWhite.colorset │ │ │ │ └── Contents.json │ │ │ └── SubGray1.colorset │ │ │ │ └── Contents.json │ │ ├── NavyPaw.imageset │ │ │ ├── Contents.json │ │ │ ├── Pet Paw(4).png │ │ │ └── Pet Paw(5).png │ │ ├── TestDoosik.imageset │ │ │ ├── Contents.json │ │ │ └── Doosik.png │ │ └── WalkingDog.imageset │ │ │ ├── Contents.json │ │ │ ├── WalkingDog 1.png │ │ │ ├── WalkingDog 2.png │ │ │ └── WalkingDog.png │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── Info.plist │ └── ProfileDrop.gif ├── SniffMeet.entitlements └── Source │ ├── App │ ├── AppDelegate.swift │ ├── AppRouter.swift │ ├── Routable.swift │ ├── SceneDelegate.swift │ └── SessionViewController.swift │ ├── Auth │ └── CreateProfile │ │ ├── Interactor │ │ └── ProfileCreateInteractor.swift │ │ ├── Presenter │ │ ├── ProfileCreatePresenter.swift │ │ └── ProfileInputPresenter.swift │ │ ├── Router │ │ ├── ProfileCreateRouter.swift │ │ └── ProfileInputRouter.swift │ │ └── View │ │ ├── ProfileCreateViewController.swift │ │ └── ProfileInputViewController.swift │ ├── Common │ ├── Base │ │ ├── BaseView.swift │ │ └── BaseViewController.swift │ ├── Constant │ │ └── Environment.swift │ ├── Design System │ │ ├── EventConstant.swift │ │ ├── InputTextField.swift │ │ ├── KeywordButton.swift │ │ ├── KeywordView.swift │ │ ├── LayoutConstant.swift │ │ ├── PaddingLabel.swift │ │ ├── PrimaryButton.swift │ │ ├── ProfileDropAnimation.swift │ │ ├── SNMColor.swift │ │ ├── SNMFont.swift │ │ ├── SNMLineTabBar.swift │ │ └── SNMToast.swift │ ├── Helper │ │ ├── AnyDecodable.swift │ │ ├── AnyJSONSerializable.swift │ │ ├── Extension + Date.swift │ │ ├── Extension + Keyboard.swift │ │ ├── Extension + Navigation.swift │ │ ├── Extension + UIApplication.swift │ │ ├── Extension + UIImage.swift │ │ ├── Extension + UIView.swift │ │ ├── Extension + UIViewController.swift │ │ └── UIControl + Publisher.swift │ └── Util │ │ └── SNMLogger.swift │ ├── DTO │ ├── DogProfileDTO.swift │ ├── MPCProfileDropDTO.swift │ ├── MateListDTO.swift │ ├── NotiListDTO.swift │ ├── SaveDeviceTokenDTO.swift │ ├── UserInfoDTO.swift │ ├── WalkAPSDTO.swift │ ├── WalkNotiDTO.swift │ └── WalkRequestDTO.swift │ ├── Entity │ ├── DogTraits │ │ ├── Keyword.swift │ │ ├── Sex.swift │ │ └── Size.swift │ ├── ImageCacheable.swift │ ├── Mate.swift │ ├── OnBoardingPage.swift │ ├── UserInfo.swift │ ├── WalkLog.swift │ └── WalkNoti.swift │ ├── Home │ ├── Alarm │ │ ├── Interactor │ │ │ └── NotificationListInteractor.swift │ │ ├── Presenter │ │ │ └── NotificationListPresenter.swift │ │ ├── Router │ │ │ └── NotificationListRouter.swift │ │ └── View │ │ │ ├── NotificationCell.swift │ │ │ └── NotificationListViewController.swift │ ├── EditProfile │ │ ├── Interactor │ │ │ └── ProfileEditInteractor.swift │ │ ├── Presenter │ │ │ └── ProfileEditPresenter.swift │ │ ├── Router │ │ │ └── ProfileEditRoutable.swift │ │ └── View │ │ │ └── ProfileEditViewController.swift │ └── Main │ │ ├── HomeModuleBuilder.swift │ │ ├── Interactor │ │ └── HomeInteractor.swift │ │ ├── Presenter │ │ └── HomePresenter.swift │ │ ├── Router │ │ ├── HomeRouter.swift │ │ └── PresentAnimator.swift │ │ └── View │ │ ├── HomeViewController.swift │ │ └── ProfileCardView.swift │ ├── LocalNetwork │ ├── MPC │ │ ├── MPCAdvertiser.swift │ │ ├── MPCBroswer.swift │ │ └── MPCManager.swift │ └── NI │ │ └── NIManager.swift │ ├── Mate │ ├── MateList │ │ ├── Interactor │ │ │ └── MateListInteractor.swift │ │ ├── Presenter │ │ │ └── MateListPresenter.swift │ │ ├── Router │ │ │ └── MateListRouter.swift │ │ └── View │ │ │ ├── AddMateButton.swift │ │ │ └── MateListViewController.swift │ └── RequestMate │ │ ├── Interactor │ │ └── RequestMateInteractor.swift │ │ ├── Presenter │ │ └── RequestMatePresenter.swift │ │ ├── Router │ │ └── RequestMateRouter.swift │ │ └── View │ │ └── RequestMateViewController.swift │ ├── OnBoarding │ └── MainBoarding │ │ ├── Interactor │ │ └── OnBoardingInteractor.swift │ │ ├── Presenter │ │ └── OnBoardingPresenter.swift │ │ ├── Router │ │ └── OnBoardingRouter.swift │ │ └── View │ │ ├── OnBoardingPageViewController.swift │ │ └── OnBoardingViewController.swift │ ├── Persistence │ ├── CacheManager.swift │ ├── KeychainManager.swift │ ├── SNMFileManager.swift │ └── UserDefaultsManager.swift │ ├── PushNotification │ ├── PushNotificationConfig.swift │ └── PushNotificationRequest.swift │ ├── Repository │ └── Auth │ │ └── DataManager.swift │ ├── SNMNetwork │ ├── Core │ │ ├── Endpoint.swift │ │ ├── HTTPStatusCode.swift │ │ ├── MultipartFormData.swift │ │ ├── NetworkProvider.swift │ │ ├── SNMNetworkResponse.swift │ │ ├── SNMRequestConvertible.swift │ │ └── SNMRequestType.swift │ ├── Helper │ │ ├── AnyEncodable.swift │ │ ├── Data + append.swift │ │ └── URLRequest + append.swift │ └── Mock │ │ ├── MockRequest.swift │ │ └── MockURLProtocol.swift │ ├── Supabase │ ├── Auth │ │ ├── Entity │ │ │ ├── Request │ │ │ │ └── SupabaseTokenRequest.swift │ │ │ ├── Response │ │ │ │ ├── SupabaseRefreshResponse.swift │ │ │ │ ├── SupabaseSessionResponse.swift │ │ │ │ └── SupabaseUserResponse.swift │ │ │ ├── SupabaseSession.swift │ │ │ └── SupabaseUser.swift │ │ ├── SessionManager.swift │ │ ├── SupabaseAuthManager.swift │ │ └── SupabaseRequest.swift │ ├── Database │ │ ├── SupabaseDatabaseManager.swift │ │ └── SupabaseDatabaseRequest.swift │ ├── Storage │ │ ├── SupabaseStorageManager.swift │ │ └── SupabaseStorageRequest.swift │ ├── SupabaseConfig.swift │ └── SupabaseError.swift │ ├── Tab │ ├── TabBarModuleBuilder.swift │ └── View │ │ └── TabBarController.swift │ ├── UseCase │ ├── ComputeUseCase │ │ ├── CalculateTimeLimitUseCase.swift │ │ ├── ConvertLocationToTextUseCase.swift │ │ └── ConvertToWalkAPSUseCase.swift │ ├── RequestUseCase │ │ ├── CheckFirstLaunchUseCase.swift │ │ ├── LoadUserInfoUseCase.swift │ │ ├── RequestLocationAuthUseCase.swift │ │ ├── RequestMateInfoUseCase.swift │ │ ├── RequestMateListUseCase.swift │ │ ├── RequestNotiListUseCase.swift │ │ ├── RequestNotificationAuthUseCase.swift │ │ ├── RequestProfileImageUseCase.swift │ │ ├── RequestUserInfoRemoteUseCase.swift │ │ ├── RequestUserLocationUseCase.swift │ │ └── RequestWalkUseCase.swift │ ├── RespondUseCase │ │ ├── RespondMateRequestUseCase.swift │ │ └── RespondWalkRequestUseCase.swift │ └── SaveUseCase │ │ ├── CreateAccountUseCase.swift │ │ ├── RegisterDeviceTokenUseCase.swift │ │ ├── RemoteSaveDeviceTokenUseCase.swift │ │ ├── SaveFirstLaunchUseCase.swift │ │ ├── SaveProfileImageUseCase.swift │ │ ├── SaveUserInfoUseCase.swift │ │ └── UpdateUserInfoUseCase.swift │ └── Walk │ ├── RequestWalk │ ├── Interactor │ │ ├── RequestWalkInteractor.swift │ │ └── SelectLocationInteractor.swift │ ├── Presenter │ │ ├── RequestWalkPresenter.swift │ │ └── SelectLocationPresenter.swift │ ├── Router │ │ ├── RequestWalkRouter.swift │ │ ├── SelectLocationModuleBuildable.swift │ │ └── SelectLocationRouter.swift │ └── View │ │ ├── CardPresentationController.swift │ │ ├── LocationSelectionView.swift │ │ ├── ProfileView.swift │ │ ├── RequestWalkViewController.swift │ │ └── SelectLocationViewController.swift │ ├── RespondWalk │ ├── Interactor │ │ ├── ProcessedWalkInteractor.swift │ │ └── RespondWalkInteractor.swift │ ├── Presenter │ │ ├── ProcessedWalkPresenter.swift │ │ ├── RespondMapPresenter.swift │ │ └── RespondWalkPresenter.swift │ ├── Router │ │ ├── ProcessedWalkRouter.swift │ │ ├── RespondMapRouter.swift │ │ └── RespondWalkRouter.swift │ └── View │ │ ├── ProcessedWalkViewController.swift │ │ ├── RespondMapViewController.swift │ │ └── RespondWalkViewController.swift │ └── WalkLog │ └── WalkLogList │ └── View │ ├── WalkLogCell.swift │ ├── WalkLogListViewController.swift │ └── WalkLogPageViewController.swift └── SupabaseTests └── SupabaseStorageTests.swift /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 🪲 Bug 11 | 12 | > 어떤 버그인지 간결하게 설명해주세요 13 | 14 | ## 상황 15 | 16 | > (가능하면) Given-When-Then 형식으로 서술해주세요 17 | 18 | ## 예상 결과 19 | 20 | > 예상했던 정상적인 결과가 어떤 것이었는지 설명해주세요 21 | 22 | ## 참고할만한 자료(선택) 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 📌Issue 11 | 12 | > 추가하려는 기능에 대해 간결하게 설명해주세요 13 | 14 | ## ✅작업 상세 내용 15 | 16 | - [ ] TODO 17 | - [ ] TODO 18 | - [ ] TODO 19 | 20 | ## 참고할만한 자료(선택) 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### 🔖  Issue Number 2 | 3 | close # 4 | 5 | ### 📙 작업 내역 6 | 7 | > 구현 내용 및 작업 했던 내역을 적어주세요. 8 | > 9 | - [ ] 작업 내역 작성 10 | 11 | ### 📋 체크리스트 12 | 13 | - [ ] Merge 하는 브랜치가 올바른가? 14 | - [ ] 코딩컨벤션을 준수하는가? 15 | - [ ] PR과 관련없는 변경사항이 없는가? 16 | - [ ] 내 코드에 대한 자기 검토가 되었는가? 17 | 18 | ### 📝 PR 특이사항 19 | 20 | > PR을 볼 때 팀원에게 알려야 할 특이사항을 알려주세요. 21 | > 22 | - 특이 사항 1 23 | - 특이 사항 2 24 | 25 | ### 👻 레퍼런스 26 | - 참고한 자료 27 | - 깃헙 위키 페이지 28 | 29 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### 🔖  Issue Number 2 | 3 | close # 4 | 5 | ### 📙 작업 내역 6 | 7 | > 구현 내용 및 작업 했던 내역을 적어주세요. 8 | > 9 | - [ ] 작업 내역 작성 10 | 11 | ### 📋 체크리스트 12 | 13 | - [ ] Merge 하는 브랜치가 올바른가? 14 | - [ ] 코딩컨벤션을 준수하는가? 15 | - [ ] PR과 관련없는 변경사항이 없는가? 16 | - [ ] 내 코드에 대한 자기 검토가 되었는가? 17 | 18 | ### 📝 PR 특이사항 19 | 20 | > PR을 볼 때 팀원에게 알려야 할 특이사항을 알려주세요. 21 | > 22 | - 특이 사항 1 23 | - 특이 사항 2 24 | 25 | ### 👻 레퍼런스 26 | - 참고한 자료 27 | - 깃헙 위키 페이지 28 | 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/xcode,macos,fastlane 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=xcode,macos,fastlane 3 | 4 | ### fastlane ### 5 | # fastlane - A streamlined workflow tool for Cocoa deployment 6 | # 7 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 8 | # screenshots whenever they are needed. 9 | # For more information about the recommended setup visit: 10 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 11 | 12 | # fastlane specific 13 | **/fastlane/report.xml 14 | 15 | # deliver temporary files 16 | **/fastlane/Preview.html 17 | 18 | # snapshot generated screenshots 19 | **/fastlane/screenshots 20 | 21 | # scan temporary files 22 | **/fastlane/test_output 23 | 24 | # Fastlane.swift runner binary 25 | **/fastlane/FastlaneRunner 26 | 27 | ### macOS ### 28 | # General 29 | .DS_Store 30 | .AppleDouble 31 | .LSOverride 32 | 33 | # Icon must end with two \r 34 | Icon 35 | 36 | 37 | # Thumbnails 38 | ._* 39 | 40 | # Files that might appear in the root of a volume 41 | .DocumentRevisions-V100 42 | .fseventsd 43 | .Spotlight-V100 44 | .TemporaryItems 45 | .Trashes 46 | .VolumeIcon.icns 47 | .com.apple.timemachine.donotpresent 48 | 49 | # Directories potentially created on remote AFP share 50 | .AppleDB 51 | .AppleDesktop 52 | Network Trash Folder 53 | Temporary Items 54 | .apdisk 55 | 56 | ### macOS Patch ### 57 | # iCloud generated files 58 | *.icloud 59 | 60 | ### Xcode ### 61 | ## User settings 62 | xcuserdata/ 63 | 64 | ## Xcode 8 and earlier 65 | *.xcscmblueprint 66 | *.xccheckout 67 | 68 | ### Xcode Patch ### 69 | *.xcodeproj/* 70 | !*.xcodeproj/project.pbxproj 71 | !*.xcodeproj/xcshareddata/ 72 | !*.xcodeproj/project.xcworkspace/ 73 | !*.xcworkspace/contents.xcworkspacedata 74 | /*.gcno 75 | **/xcshareddata/WorkspaceSettings.xcsettings 76 | 77 | # End of https://www.toptal.com/developers/gitignore/api/xcode,macos,fastlane -------------------------------------------------------------------------------- /SniffMeet/SNMNetworkTests/AnyEncodableTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyEncodableTests.swift 3 | // SNMNetworkTests 4 | // 5 | // Created by sole on 11/16/24. 6 | // 7 | 8 | import XCTest 9 | 10 | final class AnyEncodableTests: XCTestCase { 11 | func test_encodable은_인코딩_되어야_한다() throws { 12 | let _ = try AnyEncodable(1).encode() 13 | let _ = try AnyEncodable(true).encode() 14 | let _ = try AnyEncodable("string").encode() 15 | let _ = try AnyEncodable([1, 2, 3]).encode() 16 | } 17 | func test_customEncoder를_주입했을때_인코더_설정대로_인코딩되어야_한다() throws { 18 | let cusomJSONEncoder = JSONEncoder() 19 | cusomJSONEncoder.keyEncodingStrategy = .convertToSnakeCase 20 | let encodable = AnyEncodable(MockEncodable(), jsonEncoder: cusomJSONEncoder) 21 | let value = try encodable.encode() 22 | XCTAssertEqual(#"{"key_case":"value"}"#, String(data: value, encoding: .utf8)!) 23 | } 24 | 25 | struct MockEncodable: Encodable { 26 | let keyCase: String = "value" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /SniffMeet/SNMNetworkTests/EndpointTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EndpointTests.swift 3 | // SNMNetworkTests 4 | // 5 | // Created by sole on 11/16/24. 6 | // 7 | 8 | import XCTest 9 | 10 | final class EndpointTests: XCTestCase { 11 | func test_baseURL에_path가_이미_포함되어있으면_path를_합친다() { 12 | // given 13 | let url: URL = URL(string: "https://example.com/path")! 14 | // when 15 | let endpoint = Endpoint(baseURL: url, path: "login", method: .get) 16 | // then 17 | XCTAssertEqual(endpoint.absoluteURL, URL(string: "https://example.com/path/login")!) 18 | } 19 | func test_baseURL끝에_슬래시가_포함되어있고_path앞에_슬래시가_있으면_하나의_슬래시로_합친다() { 20 | // given 21 | let url: URL = URL(string: "https://example.com/path/")! 22 | // when 23 | let endpoint = Endpoint(baseURL: url, path: "login", method: .get) 24 | // then 25 | XCTAssertEqual(endpoint.absoluteURL, URL(string: "https://example.com/path/login")!) 26 | } 27 | func test_path끝에_슬래시가_포함되어_있으면_absoluteURL도_슬래시를_포함한다() { 28 | // given 29 | let url: URL = URL(string: "https://example.com/path")! 30 | // when 31 | let endpoint = Endpoint(baseURL: url, path: "login/", method: .get) 32 | // then 33 | XCTAssertEqual(endpoint.absoluteURL, URL(string: "https://example.com/path/login/")!) 34 | } 35 | func test_query를_추가할_때_queryItems가_추가된다() { 36 | // given 37 | let url: URL = URL(string: "https://example.com/path")! 38 | var endpoint = Endpoint(baseURL: url, path: "login", method: .get) 39 | // when 40 | endpoint.query = ["key": "value"] 41 | // then 42 | XCTAssertEqual(endpoint.absoluteURL.query, "key=value") 43 | } 44 | func test_query를_여러개_추가할_때_queryItems가_함께_추가된다() { 45 | // given 46 | let url: URL = URL(string: "https://example.com/path")! 47 | var endpoint = Endpoint(baseURL: url, path: "login", method: .get) 48 | // when 49 | endpoint.query = ["key": "value", "key2": "value2"] 50 | // then 51 | endpoint.absoluteURL.absoluteString.components(separatedBy: "?")[1] 52 | .components(separatedBy: "&") 53 | .map { $0.components(separatedBy: "=") } 54 | .forEach { queryItem in 55 | XCTAssertEqual(endpoint.query?[queryItem[0]], queryItem[1]) 56 | } 57 | } 58 | func test_path가_빈문자열이면_슬래시만_추가된다() { 59 | // given 60 | let url: URL = URL(string: "https://example.com/path")! 61 | // when 62 | let endpoint = Endpoint(baseURL: url, path: "", method: .get) 63 | // then 64 | XCTAssertEqual(endpoint.absoluteURL, URL(string: "https://example.com/path/")!) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /SniffMeet/SNMNetworkTests/MultipartFormDataTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultipartFormDataTests.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/17/24. 6 | // 7 | 8 | import XCTest 9 | 10 | final class MultipartFormDataTests: XCTestCase { 11 | func test_parameter가_있을때_mltp_form_data_생성() { 12 | // given 13 | let boundary: String = UUID().uuidString 14 | let parameter: [String: String] = ["team": "hgd"] 15 | // when 16 | let multipartFormData = MultipartFormData( 17 | boundary: boundary, 18 | parameters: parameter, 19 | fileName: "file.jpg", 20 | mimeType: "image/jpeg", 21 | contentData: Data("123".utf8) 22 | ) 23 | let expectedString = """ 24 | --\(boundary) 25 | Content-Disposition: form-data; name="team" 26 | 27 | hgd 28 | --\(boundary) 29 | Content-Disposition: form-data; name="file"; filename="file.jpg" 30 | Content-Type: image/jpeg 31 | 32 | 123 33 | --\(boundary)-- 34 | 35 | """.replacingOccurrences(of: "\r\n", with: "\n") 36 | let resultString = String(data: multipartFormData.compositeBody, encoding: .utf8)! 37 | .replacingOccurrences(of: "\r\n", with: "\n") 38 | // then 39 | XCTAssertEqual(resultString, expectedString) 40 | } 41 | func test_parameter가_없을때_mltp_form_data_생성() { 42 | // given 43 | let boundary: String = UUID().uuidString 44 | // when 45 | let multipartFormData = MultipartFormData( 46 | boundary: boundary, 47 | parameters: [:], 48 | fileName: "file.jpg", 49 | mimeType: "image/jpeg", 50 | contentData: Data("123".utf8) 51 | ) 52 | let expectedString = """ 53 | --\(boundary) 54 | Content-Disposition: form-data; name="file"; filename="file.jpg" 55 | Content-Type: image/jpeg 56 | 57 | 123 58 | --\(boundary)-- 59 | 60 | """.replacingOccurrences(of: "\r\n", with: "\n") 61 | let resultString = String(data: multipartFormData.compositeBody, encoding: .utf8)! 62 | .replacingOccurrences(of: "\r\n", with: "\n") 63 | // then 64 | XCTAssertEqual(resultString, expectedString) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /SniffMeet/SNMPersistenceTests/KeychainTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeychainTests.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/13/24. 6 | // 7 | 8 | import XCTest 9 | 10 | final class KeychainTests: XCTestCase { 11 | private var keychainManager: KeychainManager! 12 | private let testKey: String = "test" 13 | 14 | override func setUp() { 15 | keychainManager = KeychainManager.shared 16 | } 17 | override func tearDown() { 18 | try? keychainManager.delete(forKey: testKey) 19 | } 20 | 21 | func test_delete에서_삭제할_값이_없으면_에러를_반환한다() { 22 | // given 23 | // when 24 | // then 25 | XCTAssertThrowsError(try keychainManager.delete(forKey: testKey)) { error in 26 | XCTAssert(error is KeychainError) 27 | XCTAssertEqual(error as! KeychainError, KeychainError.keyNotFound) 28 | } 29 | } 30 | func test_delete에서_삭제할_값이_있으면_삭제한다() throws { 31 | // given 32 | try keychainManager.set(value: "123", forKey: testKey) 33 | // when 34 | // then 35 | try keychainManager.delete(forKey: testKey) 36 | } 37 | func test_set에서_이미_값이_존재하면_값을_덮어씌운다() throws { 38 | // given 39 | try keychainManager.set(value: "123", forKey: testKey) 40 | // when 41 | try keychainManager.set(value: "234", forKey: testKey) 42 | // then 43 | let value = try keychainManager.get(forKey: testKey) 44 | XCTAssertEqual("234", value) 45 | } 46 | func test_set에서_값이_존재하지_않으면_값을_새로_설정한다() throws { 47 | // given 48 | // when 49 | try keychainManager.set(value: "123", forKey: testKey) 50 | // then 51 | let value = try keychainManager.get(forKey: testKey) 52 | XCTAssertEqual("123", value) 53 | } 54 | func test_get에서_값이_존재하지_않으면_keyNotFound에러를_반환한다() { 55 | // given 56 | // when 57 | // then 58 | XCTAssertThrowsError(try keychainManager.get(forKey: testKey)) { error in 59 | XCTAssert(error is KeychainError) 60 | XCTAssertEqual(error as! KeychainError, KeychainError.keyNotFound) 61 | } 62 | } 63 | func test_get에서_값이_존재하면_값을_반환한다() throws { 64 | // given 65 | // when 66 | try keychainManager.set(value: "123", forKey: testKey) 67 | // then 68 | let value = try keychainManager.get(forKey: testKey) 69 | XCTAssertEqual("123", value) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "c3373633343d643cf4275c42d63c44d7be89c54076117a694350d515b60c2b83", 3 | "pins" : [ 4 | { 5 | "identity" : "collectionconcurrencykit", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/JohnSundell/CollectionConcurrencyKit.git", 8 | "state" : { 9 | "revision" : "b4f23e24b5a1bff301efc5e70871083ca029ff95", 10 | "version" : "0.2.0" 11 | } 12 | }, 13 | { 14 | "identity" : "cryptoswift", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", 17 | "state" : { 18 | "revision" : "678d442c6f7828def400a70ae15968aef67ef52d", 19 | "version" : "1.8.3" 20 | } 21 | }, 22 | { 23 | "identity" : "sourcekitten", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/jpsim/SourceKitten.git", 26 | "state" : { 27 | "revision" : "fd4df99170f5e9d7cf9aa8312aa8506e0e7a44e7", 28 | "version" : "0.35.0" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-argument-parser", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/apple/swift-argument-parser.git", 35 | "state" : { 36 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c", 37 | "version" : "1.5.0" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-syntax", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/swiftlang/swift-syntax.git", 44 | "state" : { 45 | "revision" : "515f79b522918f83483068d99c68daeb5116342d", 46 | "version" : "600.0.0-prerelease-2024-08-14" 47 | } 48 | }, 49 | { 50 | "identity" : "swiftlint", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/realm/SwiftLint", 53 | "state" : { 54 | "revision" : "168fb98ed1f3e343d703ecceaf518b6cf565207b", 55 | "version" : "0.57.0" 56 | } 57 | }, 58 | { 59 | "identity" : "swiftytexttable", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/scottrhoyt/SwiftyTextTable.git", 62 | "state" : { 63 | "revision" : "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3", 64 | "version" : "0.9.0" 65 | } 66 | }, 67 | { 68 | "identity" : "swxmlhash", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/drmohundro/SWXMLHash.git", 71 | "state" : { 72 | "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", 73 | "version" : "7.0.2" 74 | } 75 | }, 76 | { 77 | "identity" : "yams", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/jpsim/Yams.git", 80 | "state" : { 81 | "revision" : "3036ba9d69cf1fd04d433527bc339dc0dc75433d", 82 | "version" : "5.1.3" 83 | } 84 | } 85 | ], 86 | "version" : 3 87 | } 88 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet.xcodeproj/xcshareddata/xcschemes/SNMNetworkTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 20 | 26 | 27 | 28 | 29 | 30 | 40 | 42 | 48 | 49 | 50 | 51 | 57 | 58 | 64 | 65 | 66 | 67 | 69 | 70 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/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 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS03-SniffMeet/5b18c2429b9a9aba36b39f58d398db4cb9048e36/SniffMeet/SniffMeet/Resource/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/AppImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Frame 123.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "Frame 121.svg", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "Frame 122.svg", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/AppImage.imageset/Frame 121.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/AppImage.imageset/Frame 122.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/AppImage.imageset/Frame 123.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/BrownPaw.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "Pet Paw(3).png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "Pet Paw(2).png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/BrownPaw.imageset/Pet Paw(2).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS03-SniffMeet/5b18c2429b9a9aba36b39f58d398db4cb9048e36/SniffMeet/SniffMeet/Resource/Assets.xcassets/BrownPaw.imageset/Pet Paw(2).png -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/BrownPaw.imageset/Pet Paw(3).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS03-SniffMeet/5b18c2429b9a9aba36b39f58d398db4cb9048e36/SniffMeet/SniffMeet/Resource/Assets.xcassets/BrownPaw.imageset/Pet Paw(3).png -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/ImagePlaceholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "image-placeholder.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "image-placeholder@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "image-placeholder@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/ImagePlaceholder.imageset/image-placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS03-SniffMeet/5b18c2429b9a9aba36b39f58d398db4cb9048e36/SniffMeet/SniffMeet/Resource/Assets.xcassets/ImagePlaceholder.imageset/image-placeholder.png -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/ImagePlaceholder.imageset/image-placeholder@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS03-SniffMeet/5b18c2429b9a9aba36b39f58d398db4cb9048e36/SniffMeet/SniffMeet/Resource/Assets.xcassets/ImagePlaceholder.imageset/image-placeholder@2x.png -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/ImagePlaceholder.imageset/image-placeholder@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS03-SniffMeet/5b18c2429b9a9aba36b39f58d398db4cb9048e36/SniffMeet/SniffMeet/Resource/Assets.xcassets/ImagePlaceholder.imageset/image-placeholder@3x.png -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/MainColorSet/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/MainColorSet/MainBeige.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xC8", 9 | "green" : "0xDB", 10 | "red" : "0xEA" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xC8", 27 | "green" : "0xDB", 28 | "red" : "0xEA" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/MainColorSet/MainBrown.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xA3", 9 | "green" : "0xC0", 10 | "red" : "0xDA" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xA3", 27 | "green" : "0xC0", 28 | "red" : "0xDA" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/MainColorSet/MainNavy.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x57", 9 | "green" : "0x2C", 10 | "red" : "0x10" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x57", 27 | "green" : "0x2C", 28 | "red" : "0x10" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/MainColorSet/MainWhite.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xF6", 9 | "green" : "0xFA", 10 | "red" : "0xFE" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xF6", 27 | "green" : "0xFA", 28 | "red" : "0xFE" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/MainColorSet/SubGray1.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xF7", 9 | "green" : "0xF4", 10 | "red" : "0xF3" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0xF7", 27 | "green" : "0xF4", 28 | "red" : "0xF3" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/NavyPaw.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "Pet Paw(5).png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "Pet Paw(4).png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/NavyPaw.imageset/Pet Paw(4).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS03-SniffMeet/5b18c2429b9a9aba36b39f58d398db4cb9048e36/SniffMeet/SniffMeet/Resource/Assets.xcassets/NavyPaw.imageset/Pet Paw(4).png -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/NavyPaw.imageset/Pet Paw(5).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS03-SniffMeet/5b18c2429b9a9aba36b39f58d398db4cb9048e36/SniffMeet/SniffMeet/Resource/Assets.xcassets/NavyPaw.imageset/Pet Paw(5).png -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/TestDoosik.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Doosik.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 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/TestDoosik.imageset/Doosik.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS03-SniffMeet/5b18c2429b9a9aba36b39f58d398db4cb9048e36/SniffMeet/SniffMeet/Resource/Assets.xcassets/TestDoosik.imageset/Doosik.png -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/WalkingDog.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "WalkingDog.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "WalkingDog 1.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "WalkingDog 2.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/WalkingDog.imageset/WalkingDog 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS03-SniffMeet/5b18c2429b9a9aba36b39f58d398db4cb9048e36/SniffMeet/SniffMeet/Resource/Assets.xcassets/WalkingDog.imageset/WalkingDog 1.png -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/WalkingDog.imageset/WalkingDog 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS03-SniffMeet/5b18c2429b9a9aba36b39f58d398db4cb9048e36/SniffMeet/SniffMeet/Resource/Assets.xcassets/WalkingDog.imageset/WalkingDog 2.png -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Assets.xcassets/WalkingDog.imageset/WalkingDog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS03-SniffMeet/5b18c2429b9a9aba36b39f58d398db4cb9048e36/SniffMeet/SniffMeet/Resource/Assets.xcassets/WalkingDog.imageset/WalkingDog.png -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIUserInterfaceStyle 6 | Light 7 | NSLocalNetworkUsageDescription 8 | 'SniffMEET'은 P2P 연결을 지원하기 위해 로컬 네트워크 환경이 필요합니다. 9 | NSCameraUsageDescription 10 | 'SniffMEET'은 Nearby Interaction과 ARKit을 위해 카메라 접근이 필요합니다. 11 | NSNearbyInteractionUsageDescription 12 | 'SniffMEET'은 연결된 기기와의 거리와 방향 측정을 위해 Nearby Interaction 접근이 필요합니다. 13 | NSBonjourServices 14 | 15 | _SniffMeet._tcp 16 | _SniffMeet._udp 17 | 18 | UIApplicationSceneManifest 19 | 20 | UIApplicationSupportsMultipleScenes 21 | 22 | UISceneConfigurations 23 | 24 | UIWindowSceneSessionRoleApplication 25 | 26 | 27 | UISceneConfigurationName 28 | Default Configuration 29 | UISceneDelegateClassName 30 | $(PRODUCT_MODULE_NAME).SceneDelegate 31 | 32 | 33 | 34 | 35 | UIBackgroundModes 36 | 37 | remote-notification 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Resource/ProfileDrop.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/iOS03-SniffMeet/5b18c2429b9a9aba36b39f58d398db4cb9048e36/SniffMeet/SniffMeet/Resource/ProfileDrop.gif -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/SniffMeet.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | 8 | 9 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/App/Routable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Routable.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/19/24. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol Routable { 11 | func push(from: UIViewController, to: UIViewController, animated: Bool) 12 | func pop(from: UIViewController, animated: Bool) 13 | func present(from: UIViewController, with: UIViewController, animated: Bool) 14 | func dismiss(from: UIViewController, animated: Bool) 15 | func pushNoBottomBar(from: UIViewController, to: UIViewController, animated: Bool) 16 | func fullScreen(from: UIViewController, with: UIViewController, animated: Bool) 17 | } 18 | 19 | extension Routable { 20 | func push(from: UIViewController, to: UIViewController, animated: Bool) { 21 | from.navigationController?.pushViewController(to, animated: animated) 22 | } 23 | 24 | func pop(from: UIViewController, animated: Bool) { 25 | from.navigationController?.popViewController(animated: animated) 26 | } 27 | 28 | func present(from: UIViewController, with: UIViewController, animated: Bool) { 29 | from.present(with, animated: animated) 30 | } 31 | 32 | func dismiss(from: UIViewController, animated: Bool) { 33 | from.dismiss(animated: animated) 34 | } 35 | 36 | func pushNoBottomBar(from: UIViewController, to: UIViewController, animated: Bool) { 37 | to.hidesBottomBarWhenPushed = true 38 | from.navigationController?.pushViewController(to, animated: animated) 39 | } 40 | 41 | func fullScreen(from: UIViewController, with: UIViewController, animated: Bool) { 42 | with.modalPresentationStyle = .overFullScreen 43 | from.present(with, animated: animated) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/App/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/4/24. 6 | // 7 | 8 | import UIKit 9 | 10 | final class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | var window: UIWindow? 12 | var appRouter: AppRouter? 13 | private weak var sessionController: SessionViewController? 14 | private let convertToAPSUseCase: ConvertToWalkAPSUseCase = ConvertToWalkAPSUseCaseImpl() 15 | 16 | func scene( 17 | _ scene: UIScene, 18 | willConnectTo session: UISceneSession, 19 | options connectionOptions: UIScene.ConnectionOptions 20 | ) { 21 | guard let windowScene = (scene as? UIWindowScene) else { return } 22 | window = UIWindow(windowScene: windowScene) 23 | appRouter = AppRouter(window: window) 24 | let sessionController = SessionViewController(appRouter: appRouter) 25 | self.sessionController = sessionController 26 | 27 | if let response = connectionOptions.notificationResponse { 28 | routePushNotification(response: response) 29 | } 30 | window?.rootViewController = sessionController 31 | window?.makeKeyAndVisible() 32 | } 33 | 34 | /// push notification을 통해 앱에 처음 진입한 경우 라우팅을 진행합니다. 35 | private func routePushNotification(response: UNNotificationResponse) { 36 | let userInfo = response.notification.request.content.userInfo 37 | if let walkAPS = convertToAPSUseCase.execute(walkAPSUserInfo: userInfo) { 38 | sessionController?.walkNoti = walkAPS.notification.toEntity() 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Auth/CreateProfile/Presenter/ProfileCreatePresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileSetupPresenter.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/14/24. 6 | // 7 | import Foundation 8 | import UIKit 9 | 10 | protocol ProfileCreatePresentable : AnyObject{ 11 | var dogInfo: DogInfo { get set } 12 | var view: ProfileCreateViewable? { get set } 13 | var interactor: ProfileCreateInteractable? { get set } 14 | var router: ProfileCreateRoutable? { get set } 15 | 16 | func didTapSubmitButton(nickname: String, image: UIImage?) 17 | } 18 | 19 | protocol DogInfoInteractorOutput: AnyObject { 20 | func didSaveUserInfo() 21 | func didFailToSaveUserInfo(error: Error) 22 | } 23 | 24 | 25 | final class ProfileCreatePresenter: ProfileCreatePresentable { 26 | var dogInfo: DogInfo 27 | weak var view: ProfileCreateViewable? 28 | var interactor: ProfileCreateInteractable? 29 | var router: ProfileCreateRoutable? 30 | 31 | init(dogInfo: DogInfo, 32 | view: ProfileCreateViewable? = nil, 33 | interactor: ProfileCreateInteractable? = nil, 34 | router: ProfileCreateRoutable? = nil) 35 | { 36 | self.dogInfo = dogInfo 37 | self.view = view 38 | self.interactor = interactor 39 | self.router = router 40 | } 41 | 42 | func didTapSubmitButton(nickname: String, image: UIImage?) { 43 | let pngData = interactor?.convertImageToPNGData(image: image) 44 | let jpgData = interactor?.convertImageToJPGData(image: image) 45 | let userInfo = UserInfo( 46 | name: dogInfo.name, 47 | age: dogInfo.age, 48 | sex: dogInfo.sex, 49 | sexUponIntake: dogInfo.sexUponIntake, 50 | size: dogInfo.size, 51 | keywords: dogInfo.keywords, 52 | nickname: nickname, 53 | profileImage: nil 54 | ) 55 | // TODO: SubmitButton disable 필요 56 | interactor?.signInWithProfileData( 57 | dogInfo: userInfo, 58 | imageData: (png: pngData, jpg: jpgData) 59 | ) 60 | } 61 | } 62 | 63 | extension ProfileCreatePresenter: DogInfoInteractorOutput { 64 | func didSaveUserInfo() { 65 | // TODO: submit button enable 66 | guard let view else { return } 67 | router?.presentMainScreen(from: view) 68 | } 69 | 70 | func didFailToSaveUserInfo(error: any Error) { 71 | // TODO: - alert 올리는데 어떻게 올릴지 정하기 72 | // TODO: submit button enable 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Auth/CreateProfile/Presenter/ProfileInputPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileInputPresenter.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/14/24. 6 | // 7 | 8 | protocol ProfileInputPresentable { 9 | var view: ProfileInputViewable? { get set } 10 | var router: ProfileInputRoutable? { get set } 11 | func moveToProfileCreateView(with newDogDetailInfo: DogInfo) 12 | } 13 | 14 | 15 | final class ProfileInputPresenter: ProfileInputPresentable { 16 | weak var view: ProfileInputViewable? 17 | var router: ProfileInputRoutable? 18 | 19 | func moveToProfileCreateView(with newDogDetailInfo: DogInfo) { 20 | guard let view else { return } 21 | router?.presentPostCreateScreen(from: view, with: newDogDetailInfo) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Auth/CreateProfile/Router/ProfileCreateRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileCreateRouter.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/14/24. 6 | // 7 | import UIKit 8 | 9 | protocol ProfileCreateRoutable { 10 | func presentMainScreen(from view: ProfileCreateViewable) 11 | } 12 | 13 | protocol ProfileCreateBuildable { 14 | static func createProfileCreateModule(dogDetailInfo: DogInfo) -> UIViewController 15 | } 16 | 17 | final class ProfileCreateRouter: ProfileCreateRoutable { 18 | func presentMainScreen(from view: any ProfileCreateViewable) { 19 | Task { @MainActor in 20 | if let sceneDelegate = UIApplication.shared.connectedScenes 21 | .first(where: { $0.activationState == .foregroundActive })? 22 | .delegate as? SceneDelegate { 23 | if let router = sceneDelegate.appRouter { 24 | router.moveToHomeScreen() 25 | } 26 | } 27 | } 28 | } 29 | } 30 | 31 | extension ProfileCreateRouter: ProfileCreateBuildable { 32 | static func createProfileCreateModule(dogDetailInfo: DogInfo) -> UIViewController { 33 | let saveUserInfoUseCase: SaveUserInfoUseCase = 34 | SaveUserInfoUseCaseImpl( 35 | localDataManager: LocalDataManager(), 36 | imageManager: SNMFileManager() 37 | ) 38 | let saveProfileImageUseCase: SaveProfileImageUseCase = 39 | SaveProfileImageUseCaseImpl( 40 | remoteImageManager: SupabaseStorageManager( 41 | networkProvider: SNMNetworkProvider() 42 | ), 43 | userDefaultsManager: UserDefaultsManager.shared 44 | ) 45 | let createAccountUseCase: CreateAccountUseCase = CreateAccountUseCaseImpl() 46 | 47 | let view: ProfileCreateViewable & UIViewController = ProfileCreateViewController() 48 | let presenter: ProfileCreatePresentable & DogInfoInteractorOutput 49 | = ProfileCreatePresenter(dogInfo: dogDetailInfo) 50 | let interactor: ProfileCreateInteractable = 51 | ProfileCreateInteractor( 52 | saveUserInfoUseCase: saveUserInfoUseCase, 53 | saveProfileImageUseCase: saveProfileImageUseCase, 54 | saveUserInfoRemoteUseCase: createAccountUseCase 55 | ) 56 | let router: ProfileCreateRoutable & ProfileCreateBuildable = ProfileCreateRouter() 57 | 58 | view.presenter = presenter 59 | presenter.view = view 60 | presenter.router = router 61 | presenter.interactor = interactor 62 | interactor.presenter = presenter 63 | 64 | return view 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Auth/CreateProfile/Router/ProfileInputRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Untitled.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/14/24. 6 | // 7 | import UIKit 8 | 9 | protocol ProfileInputRoutable { 10 | func presentPostCreateScreen(from view: ProfileInputViewable, with dogDetail: DogInfo) 11 | } 12 | 13 | protocol ProfileInputBuildable { 14 | static func createProfileInputModule() -> UIViewController 15 | } 16 | 17 | final class ProfileInputRouter: ProfileInputRoutable { 18 | func presentPostCreateScreen(from view: ProfileInputViewable, with dogDetail: DogInfo) { 19 | let profileCreateViewController = 20 | ProfileCreateRouter.createProfileCreateModule(dogDetailInfo: dogDetail) 21 | 22 | if let sourceView = view as? UIViewController { 23 | sourceView.navigationController?.pushViewController(profileCreateViewController, 24 | animated: true) 25 | } 26 | } 27 | } 28 | 29 | extension ProfileInputRouter: ProfileInputBuildable { 30 | static func createProfileInputModule() -> UIViewController { 31 | let view: ProfileInputViewable & UIViewController = ProfileInputViewController() 32 | var presenter: ProfileInputPresentable = ProfileInputPresenter() 33 | let router: ProfileInputRoutable & ProfileInputBuildable = ProfileInputRouter() 34 | 35 | view.presenter = presenter 36 | presenter.view = view 37 | presenter.router = router 38 | 39 | return view 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Base/BaseView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseView.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/12/24. 6 | // 7 | 8 | import UIKit 9 | 10 | class BaseView: UIView { 11 | override init(frame: CGRect) { 12 | super.init(frame: frame) 13 | 14 | backgroundColor = .white 15 | 16 | configureAttributes() 17 | configureHierarchy() 18 | configureConstraints() 19 | bind() 20 | } 21 | 22 | /// frame이 zero로 설정됩니다. 23 | init() { 24 | super.init(frame: .zero) 25 | 26 | backgroundColor = .white 27 | 28 | configureAttributes() 29 | configureHierarchy() 30 | configureConstraints() 31 | bind() 32 | } 33 | 34 | @available(*, unavailable) 35 | required init?(coder: NSCoder) { 36 | fatalError("init(coder:) has not been implemented") 37 | } 38 | 39 | func configureAttributes() {} 40 | func configureHierarchy() {} 41 | func configureConstraints() {} 42 | func bind() {} 43 | } 44 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Base/BaseViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseViewController.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/12/24. 6 | // 7 | 8 | import UIKit 9 | 10 | class BaseViewController: UIViewController { 11 | init() { 12 | super.init(nibName: nil, bundle: nil) 13 | } 14 | 15 | @available(*, unavailable) 16 | required init?(coder: NSCoder) { 17 | fatalError("init(coder:) has not been implemented") 18 | } 19 | 20 | override func viewDidLoad() { 21 | super.viewDidLoad() 22 | 23 | view.backgroundColor = .white 24 | 25 | configureAttributes() 26 | configureHierachy() 27 | configureConstraints() 28 | bind() 29 | } 30 | 31 | func configureAttributes() {} 32 | func configureHierachy() {} 33 | func configureConstraints() {} 34 | func bind() {} 35 | } 36 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Constant/Environment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Environment.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/25/24. 6 | // 7 | 8 | enum Environment { 9 | enum UserDefaultsKey { 10 | static let profileImage: String = "profileImage" 11 | static let dogInfo: String = "dogInfo" 12 | static let expiresAt: String = "expiresAt" 13 | static let mateList: String = "mateList" 14 | static let isFirstLaunch: String = "isFirstLaunch" 15 | } 16 | 17 | enum KeychainKey { 18 | static let accessToken: String = "accessToken" 19 | static let refreshToken: String = "refreshToken" 20 | static let deviceToken: String = "deviceToken" 21 | } 22 | 23 | enum FileManagerKey { 24 | static let profileImage: String = "profile" 25 | } 26 | 27 | enum SupabaseTableName { 28 | static let userInfo = "user_info" 29 | static let notification = "notification" 30 | static let notificationList = "notification_list" 31 | static let matelist = "mate_list" 32 | static let matelistFunction = "rpc/get_user_info_from_mate_list" 33 | static let notificationListFunction = "rpc/notification_list" 34 | static let walkRequest = "walk_request" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Design System/EventConstant.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Untitled.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 12/2/24. 6 | // 7 | 8 | enum EventConstant { 9 | static let debounceInterval: Double = 0.5 10 | static let throttleInterval: Double = 1.0 11 | } 12 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Design System/InputTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BasicTextField.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/7/24. 6 | // 7 | 8 | import UIKit 9 | 10 | final class InputTextField: UITextField { 11 | private let padding = UIEdgeInsets(top: Context.verticalPadding, 12 | left: Context.horizontalPadding, 13 | bottom: Context.verticalPadding, 14 | right: Context.horizontalPadding) 15 | init(placeholder: String) { 16 | super.init(frame: .zero) 17 | setupConfiguration(placeholder: placeholder) 18 | } 19 | @available(*, unavailable) 20 | required init?(coder: NSCoder) { 21 | super.init(coder: coder) 22 | } 23 | private func setupConfiguration(placeholder: String) { 24 | backgroundColor = SNMColor.subGray1 25 | self.placeholder = placeholder 26 | borderStyle = .none 27 | layer.cornerRadius = Context.cornerRadius 28 | clearButtonMode = .always 29 | } 30 | override func textRect(forBounds bounds: CGRect) -> CGRect { 31 | return bounds.inset(by: padding) 32 | } 33 | override func editingRect(forBounds bounds: CGRect) -> CGRect { 34 | return bounds.inset(by: padding) 35 | } 36 | } 37 | 38 | extension InputTextField { 39 | private enum Context { 40 | static let verticalPadding: CGFloat = 16 41 | static let horizontalPadding: CGFloat = 20 42 | static let cornerRadius: CGFloat = 10 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Design System/KeywordButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HashTagButton.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/7/24. 6 | // 7 | 8 | import UIKit 9 | 10 | final class KeywordButton: UIButton { 11 | init(title: String) { 12 | super.init(frame: .zero) 13 | setupConfiguration(title: title) 14 | addAction(UIAction { [weak self] _ in self?.isSelected.toggle()}, for: .touchUpInside) 15 | } 16 | 17 | @available(*, unavailable) 18 | required init?(coder: NSCoder) { 19 | super.init(coder: coder) 20 | } 21 | 22 | private func setupConfiguration(title: String) { 23 | var configuration = UIButton.Configuration.filled() 24 | let handler: UIButton.ConfigurationUpdateHandler = { button in 25 | switch button.state { 26 | case .selected: 27 | button.configuration?.baseBackgroundColor = SNMColor.mainBrown 28 | case .normal: 29 | button.configuration?.baseBackgroundColor = SNMColor.disabledGray 30 | default: 31 | break 32 | } 33 | } 34 | configuration.title = title 35 | configuration.baseForegroundColor = SNMColor.white 36 | configuration.baseForegroundColor = SNMColor.black 37 | configuration.cornerStyle = .capsule 38 | configuration.contentInsets = NSDirectionalEdgeInsets( 39 | top: 6, 40 | leading: 8, 41 | bottom: 6, 42 | trailing: 8 43 | ) 44 | configuration.attributedTitle = AttributedString( 45 | title, 46 | attributes: AttributeContainer( 47 | [.font: UIFont.systemFont(ofSize: 12.0, weight: .regular)] 48 | ) 49 | ) 50 | 51 | self.configuration = configuration 52 | self.configurationUpdateHandler = handler 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Design System/KeywordView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeywordView.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/7/24. 6 | // 7 | import UIKit 8 | 9 | final class KeywordView: UILabel { 10 | private var insets = UIEdgeInsets( 11 | top: Context.verticalPadding, 12 | left: Context.horizontalPadding, 13 | bottom: Context.verticalPadding, 14 | right: Context.horizontalPadding 15 | ) 16 | override var intrinsicContentSize: CGSize { 17 | let size = super.intrinsicContentSize 18 | return CGSize( 19 | width: size.width + insets.left + insets.right, 20 | height: size.height + insets.top + insets.bottom 21 | ) 22 | } 23 | override var text: String? { 24 | didSet { 25 | super.text = "#\(text ?? "")" 26 | } 27 | } 28 | 29 | init(title: String) { 30 | super.init(frame: .zero) 31 | text = title 32 | setupConfiguration() 33 | } 34 | 35 | @available(*, unavailable) 36 | required init?(coder: NSCoder) { 37 | super.init(coder: coder) 38 | } 39 | 40 | override func drawText(in rect: CGRect) { 41 | let insetRect = rect.inset(by: insets) 42 | super.drawText(in: insetRect) 43 | } 44 | 45 | private func setupConfiguration() { 46 | font = UIFont.systemFont(ofSize: 14) 47 | layer.cornerRadius = Context.cornerRadius 48 | layer.masksToBounds = true 49 | self.backgroundColor = SNMColor.white 50 | } 51 | } 52 | extension KeywordView { 53 | private enum Context { 54 | static let verticalPadding: CGFloat = 4 55 | static let horizontalPadding: CGFloat = 8 56 | static let cornerRadius: CGFloat = 13 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Design System/LayoutConstant.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LayoutConstant.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/11/24. 6 | // 7 | 8 | import CoreGraphics 9 | 10 | enum LayoutConstant { 11 | /// 24 12 | static let iconSize: CGFloat = 24 13 | /// 24 14 | static let horizontalPadding: CGFloat = 24 15 | /// 8 16 | static let xsmallVerticalPadding: CGFloat = 8 17 | /// 12 18 | static let smallVerticalPadding: CGFloat = 12 19 | /// 16 20 | static let regularVerticalPadding: CGFloat = 16 21 | /// 24 22 | static let mediumVerticalPadding: CGFloat = 24 23 | /// 30 24 | static let largeVerticalPadding: CGFloat = 30 25 | /// 32 26 | static let xlargeVerticalPadding: CGFloat = 32 27 | /// 16 28 | static let smallHorizontalPadding: CGFloat = 16 29 | /// 8 30 | static let tagHorizontalSpacing: CGFloat = 8 31 | /// 4 32 | static let navigationItemSpacing: CGFloat = 4 33 | /// 12 34 | static let edgePadding: CGFloat = 12 35 | } 36 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Design System/PaddingLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PaddingLabel.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/19/24. 6 | // 7 | 8 | import UIKit 9 | 10 | final class PaddingLabel: UILabel { 11 | private let padding = LayoutConstant.edgePadding 12 | private var paddingType: PaddingType 13 | 14 | init(paddingType: PaddingType = .all) { 15 | self.paddingType = paddingType 16 | super.init(frame: .zero) 17 | } 18 | 19 | @available(*, unavailable) 20 | required init?(coder: NSCoder) { 21 | self.paddingType = .all 22 | super.init(coder: coder) 23 | } 24 | override func drawText(in rect: CGRect) { 25 | var insets = UIEdgeInsets(top: padding, left: padding, bottom: padding, right: padding) 26 | switch paddingType { 27 | case .horizontal: 28 | insets = UIEdgeInsets(top: 0, left: padding, bottom: 0, right: padding) 29 | case .vertical: 30 | insets = UIEdgeInsets(top: padding, left: 0, bottom: padding, right: 0) 31 | case .all: 32 | break 33 | } 34 | super.drawText(in: rect.inset(by: insets)) 35 | } 36 | override var intrinsicContentSize: CGSize { 37 | let size = super.intrinsicContentSize 38 | switch paddingType { 39 | case .horizontal: 40 | return CGSize(width: size.width + padding * 2, height: size.height) 41 | case .vertical: 42 | return CGSize(width: size.width, height: size.height + padding * 2) 43 | case .all: 44 | return CGSize(width: size.width + padding * 2, height: size.height + padding * 2) 45 | } 46 | } 47 | override var bounds: CGRect { 48 | didSet { 49 | switch paddingType { 50 | case .horizontal: 51 | preferredMaxLayoutWidth = bounds.width - ( padding * 2 ) 52 | case .vertical: 53 | preferredMaxLayoutWidth = bounds.width 54 | case .all: 55 | preferredMaxLayoutWidth = bounds.width - ( padding * 2 ) 56 | } 57 | } 58 | } 59 | enum PaddingType { 60 | case horizontal 61 | case vertical 62 | case all 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Design System/PrimaryButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrimaryButton.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/7/24. 6 | // 7 | 8 | import UIKit 9 | 10 | final class PrimaryButton: UIButton { 11 | init(title: String) { 12 | super.init(frame: .zero) 13 | setupConfiguration(title: title) 14 | } 15 | 16 | @available(*, unavailable) 17 | required init?(coder: NSCoder) { 18 | super.init(coder: coder) 19 | } 20 | 21 | private func setupConfiguration(title: String) { 22 | var configuration = UIButton.Configuration.filled() 23 | configuration.title = title 24 | configuration.baseBackgroundColor = SNMColor.mainNavy 25 | configuration.baseForegroundColor = SNMColor.white 26 | configuration.cornerStyle = .large 27 | configuration.contentInsets = NSDirectionalEdgeInsets( 28 | top: 20, 29 | leading: 16, 30 | bottom: 20, 31 | trailing: 16 32 | ) 33 | configuration.attributedTitle = AttributedString( 34 | title, 35 | attributes: AttributeContainer( 36 | [.font: UIFont.systemFont(ofSize: 16.0, weight: .bold)] 37 | ) 38 | ) 39 | 40 | let handler: UIButton.ConfigurationUpdateHandler = { button in 41 | switch button.state { 42 | case .disabled: 43 | button.configuration?.background.backgroundColor = SNMColor.disabledGray 44 | case .normal: 45 | button.configuration?.background.backgroundColor = SNMColor.mainNavy 46 | default: 47 | break 48 | } 49 | } 50 | 51 | self.configuration = configuration 52 | self.configurationUpdateHandler = handler 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Design System/SNMColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SNMColor.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/11/24. 6 | // 7 | 8 | import UIKit 9 | 10 | enum SNMColor { 11 | static let black: UIColor = UIColor.black 12 | static let white: UIColor = UIColor.white 13 | static let mainNavy: UIColor = UIColor.mainNavy 14 | static let mainWhite: UIColor = UIColor.mainWhite 15 | static let mainBrown: UIColor = UIColor.mainBrown 16 | static let mainBeige: UIColor = UIColor.mainBeige 17 | static let subGray1: UIColor = UIColor.subGray1 18 | static let subGray2: UIColor = UIColor(hex: 0xC0C0C0) 19 | static let subGray3: UIColor = UIColor(hex: 0x999999) 20 | static let text1: UIColor = UIColor(hex: 0xCCD7DC) 21 | static let text2: UIColor = UIColor(hex: 0x8E8E93) 22 | static let text3: UIColor = UIColor(hex: 0xC9C9C9) 23 | static let disabledGray: UIColor = UIColor(hex: 0xE6EAEC) 24 | static let warningRed: UIColor = UIColor(hex: 0xF64545) 25 | static let subBlack1: UIColor = UIColor(hex: 0x383838) 26 | static let cardIconButtonBackground: UIColor = UIColor(hex: 0xECECEC, alpha: 0.87) 27 | } 28 | 29 | // MARK: - UIColor+init 30 | 31 | extension UIColor { 32 | /// hex 값은 0x000000 (0x 뒤 여섯자리(0-F) 값)과 같은 형식만 넣어야합니다. 예시: 0xC0C0C, 0xECECEC, 0xF64545 33 | convenience init(hex: UInt, alpha: CGFloat = 1) { 34 | self.init( 35 | red: Double((hex >> 16) & 0xff) / 255, 36 | green: Double((hex >> 8) & 0xff) / 255, 37 | blue: Double((hex >> 0) & 0xff) / 255, 38 | alpha: alpha 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Design System/SNMFont.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SNMFont.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/11/24. 6 | // 7 | 8 | import UIKit 9 | 10 | enum SNMFont { 11 | /// size: 34, weight: bold 12 | static let bigLogoTitle: UIFont = UIFont.systemFont(ofSize: 48, weight: .heavy) 13 | static let smallLogoTitle: UIFont = UIFont.systemFont(ofSize: 20, weight: .medium) 14 | static let largeTitle: UIFont = UIFont.systemFont(ofSize: 34, weight: .bold) 15 | /// size: 32,weight: bold 16 | static let title1: UIFont = UIFont.systemFont(ofSize: 32, weight: .bold) 17 | /// size: 24, weight: heavy 18 | static let title2: UIFont = UIFont.systemFont(ofSize: 24, weight: .heavy) 19 | /// size: 24,weight: semibold 20 | static let title3: UIFont = UIFont.systemFont(ofSize: 24, weight: .semibold) 21 | /// size: 16, weight: regular 22 | static let body: UIFont = UIFont.systemFont(ofSize: 16, weight: .regular) 23 | /// size: 17, weight: semibold 24 | static let headline: UIFont = UIFont.systemFont(ofSize: 17, weight: .semibold) 25 | /// size: 14, weight: regular 26 | static let subheadline: UIFont = UIFont.systemFont(ofSize: 14, weight: .regular) 27 | /// size: 16, weight: semibold 28 | static let callout: UIFont = UIFont.systemFont(ofSize: 16, weight: .semibold) 29 | /// size: 16, weight: bold 30 | static let callout2: UIFont = UIFont.systemFont(ofSize: 16, weight: .bold) 31 | /// size: 12, weight: regular 32 | static let caption: UIFont = UIFont.systemFont(ofSize: 12, weight: .regular) 33 | /// size: 12, weight: semibold 34 | static let caption2: UIFont = UIFont.systemFont(ofSize: 12, weight: .semibold) 35 | } 36 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Helper/AnyDecodable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyDecodable.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/28/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct AnyDecodable { 11 | private let data: Data 12 | private let jsonDecoder: JSONDecoder 13 | 14 | init( 15 | data: Data, 16 | jsonDecoder: JSONDecoder = AnyDecodable.defaultDecoder 17 | ) { 18 | self.data = data 19 | self.jsonDecoder = jsonDecoder 20 | } 21 | 22 | func decode(type: T.Type) throws -> T { 23 | try jsonDecoder.decode(type, from: data) 24 | } 25 | } 26 | 27 | extension AnyDecodable { 28 | static let defaultDecoder: JSONDecoder = JSONDecoder() 29 | } 30 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Helper/AnyJSONSerializable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyJSONSerializable.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/27/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct AnyJSONSerializable { 11 | private let value: Any 12 | private let jsonDecoder: JSONDecoder 13 | 14 | init?( 15 | value: Any, 16 | jsonDecoder: JSONDecoder = AnyDecodable.defaultDecoder 17 | ) { 18 | guard JSONSerialization.isValidJSONObject(value) else { return nil } 19 | self.value = value 20 | self.jsonDecoder = jsonDecoder 21 | } 22 | 23 | func decode(type: T.Type) throws -> T { 24 | let serializedData = try JSONSerialization.data(withJSONObject: value) 25 | let decodedData = try AnyDecodable(data: serializedData).decode(type: T.self) 26 | return decodedData 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Helper/Extension + Date.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extension + Date.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/26/24. 6 | // 7 | import Foundation 8 | 9 | extension Date { 10 | func secondsDifferenceFromNow() -> Int { 11 | let currentDate = Date() 12 | let calendar = Calendar.current 13 | let components = calendar.dateComponents([.second], from: self, to: currentDate) 14 | 15 | return components.second ?? 0 16 | } 17 | func hoursDifferenceFromNow() -> Int { 18 | let currentDate = Date() 19 | let calendar = Calendar.current 20 | let components = calendar.dateComponents([.hour], from: self, to: currentDate) 21 | 22 | return components.hour ?? 0 23 | } 24 | func convertDateToISO8601String() -> String { 25 | let dateFormatter = DateFormatter() 26 | dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" 27 | dateFormatter.timeZone = TimeZone(abbreviation: "UTC") // UTC 설정 28 | dateFormatter.locale = Locale(identifier: "en_US_POSIX") // 권장 로케일 설정 29 | return dateFormatter.string(from: self) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Helper/Extension + Keyboard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extension + Keyboard.swift 3 | // SniffMeet 4 | // 5 | // Created by 배현진 on 11/12/24. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIViewController { 11 | func hideKeyboardWhenTappedAround() { 12 | let tap = UITapGestureRecognizer(target: self, 13 | action: #selector(self.dismissKeyboard)) 14 | tap.cancelsTouchesInView = false 15 | view.addGestureRecognizer(tap) 16 | } 17 | 18 | @objc func dismissKeyboard() { 19 | view.endEditing(true) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Helper/Extension + Navigation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extension + Navigation.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/7/24. 6 | // 7 | import UIKit 8 | 9 | extension UINavigationItem { 10 | 11 | func setupConfiguration(title: String) { 12 | self.title = title 13 | 14 | guard let navyColor = UIColor(named: "MainNavy") else { return } 15 | let appearance = UINavigationBarAppearance() 16 | appearance.titleTextAttributes = [.foregroundColor: navyColor] 17 | appearance.largeTitleTextAttributes = [.foregroundColor: navyColor] 18 | 19 | standardAppearance = appearance 20 | scrollEdgeAppearance = appearance 21 | } 22 | } 23 | 24 | extension UINavigationBar { 25 | // chevrom 색을 mainNavy로 설정 및 'back' title을 제거한 back 버튼 26 | func configureBackButton() { 27 | configureBackButton(color: SNMColor.mainNavy, title: "") 28 | } 29 | 30 | func configureBackButton(color: UIColor, title: String ) { 31 | tintColor = color 32 | topItem?.title = title 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Helper/Extension + UIApplication.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extension + UIAplplication.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 12/3/24. 6 | // 7 | import UIKit 8 | 9 | extension UIApplication { 10 | static var screenSize: CGSize { 11 | guard let windowScene = shared.connectedScenes.first as? UIWindowScene else { 12 | return UIScreen.main.bounds.size 13 | } 14 | return windowScene.screen.bounds.size 15 | } 16 | 17 | static var screenHeight: CGFloat { 18 | return screenSize.height 19 | } 20 | 21 | static var screenWidth: CGFloat { 22 | return screenSize.width 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Helper/Extension + UIImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extension + UIImage.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/29/24. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIImage { 11 | func clipToSquareWithBackgroundColor(with convertedSize: CGFloat) -> UIImage? { 12 | let aspectRatio = size.width / size.height 13 | 14 | var cropRect: CGRect 15 | if aspectRatio > 1 { 16 | let newHeight = convertedSize 17 | let newWidth = convertedSize * aspectRatio 18 | cropRect = .init( 19 | origin: .init( 20 | x: (convertedSize - newWidth) / 2, 21 | y: (convertedSize - newHeight) / 2 22 | ), 23 | size: CGSize(width: newWidth, height: newHeight) 24 | ) 25 | } else { 26 | let newWidth = convertedSize 27 | let newHeight = convertedSize / aspectRatio 28 | cropRect = .init( 29 | origin: .init( 30 | x: (convertedSize - newWidth) / 2, 31 | y: (convertedSize - newHeight) / 2 32 | ), 33 | size: CGSize( 34 | width: newWidth, 35 | height: newHeight 36 | ) 37 | ) 38 | } 39 | let renderer = UIGraphicsImageRenderer( 40 | size: CGSize(width: convertedSize, height: convertedSize) 41 | ) 42 | let resultImage = renderer.image { context in 43 | SNMColor.subGray1.setFill() 44 | UIRectFill( 45 | .init( 46 | origin: .zero, 47 | size: .init(width: convertedSize, height: convertedSize) 48 | ) 49 | ) 50 | context.cgContext.translateBy(x: convertedSize / 2, y: convertedSize / 2) 51 | context.cgContext.scaleBy(x: 1.0, y: -1.0) 52 | context.cgContext.translateBy(x: -convertedSize / 2, y: -convertedSize / 2) 53 | guard let cgImage else { return } 54 | context.cgContext.draw(cgImage, in: cropRect) 55 | } 56 | return resultImage 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Helper/Extension + UIView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extension + UIView.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/9/24. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIView { 11 | func makeViewCircular() { 12 | layer.cornerRadius = frame.size.width / 2 13 | clipsToBounds = true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Helper/Extension + UIViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extension + UIViewController.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/27/24. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIViewController { 11 | /// 현재 Scene에서 가장 앞에 presented 된 ViewController를 찾습니다. 12 | static var topMostViewController: UIViewController? { 13 | let base = UIApplication.shared.connectedScenes 14 | .compactMap { ($0 as? UIWindowScene)?.keyWindow?.rootViewController } 15 | .first 16 | var current = base 17 | while let presented = current?.presentedViewController { 18 | current = presented 19 | } 20 | return current 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Helper/UIControl + Publisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIControl + Publisher.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/18/24. 6 | // 7 | 8 | import Combine 9 | import UIKit 10 | 11 | extension UIControl { 12 | func publisher(event: UIControl.Event) -> UIControlEventPublisher { 13 | UIControlEventPublisher(control: self, event: event) 14 | } 15 | } 16 | 17 | struct UIControlEventPublisher: Publisher { 18 | typealias Output = Void 19 | typealias Failure = Never 20 | private let control: UIControl 21 | private let event: UIControl.Event 22 | 23 | init(control: UIControl, event: UIControl.Event) { 24 | self.control = control 25 | self.event = event 26 | } 27 | 28 | func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { 29 | let subscription: UIControlEventSubscription = UIControlEventSubscription( 30 | subscriber: subscriber, 31 | control: control, 32 | event: event 33 | ) 34 | subscriber.receive(subscription: subscription) 35 | } 36 | } 37 | 38 | final class UIControlEventSubscription: Subscription where S: Subscriber, 39 | S.Input == Void, 40 | S.Failure == Never { 41 | private weak var control: UIControl? 42 | private var subscriber: S? 43 | 44 | init(subscriber: S? = nil, control: UIControl, event: UIControl.Event) { 45 | self.subscriber = subscriber 46 | self.control = control 47 | control.addTarget(self, action: #selector(processControlAction), for: event) 48 | } 49 | 50 | func request(_ demand: Subscribers.Demand) {} 51 | func cancel() { 52 | subscriber = nil 53 | control?.removeTarget(self, action: #selector(processControlAction), for: .allEvents) 54 | } 55 | @objc func processControlAction() { 56 | _ = subscriber?.receive(()) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Common/Util/SNMLogger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SNMLogger.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/20/24. 6 | // 7 | 8 | import OSLog 9 | 10 | enum SNMLogger { 11 | private static let logger: Logger = Logger(subsystem: "SniffMeet", category: "SNMLogger") 12 | 13 | /// debug 레벨에서 사용합니다. 14 | static func print(_ message: String...) { 15 | logger.debug("⚙️ \(message.joined(separator: " "))") 16 | } 17 | static func error(_ message: String...) { 18 | logger.error("🚨 \(message.joined(separator: " "))") 19 | } 20 | static func info(_ message: String...) { 21 | logger.info("📄 \(message.joined(separator: " "))") 22 | } 23 | static func log(level: OSLogType = .default, _ message: String...) { 24 | logger.log(level: level, "\(message.joined(separator: " "))") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/DTO/DogProfileDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DogProfileDTO.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/27/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct DogProfileDTO: Codable { 11 | let id: UUID 12 | let name: String 13 | let keywords: [Keyword] 14 | var profileImage: Data? 15 | } 16 | 17 | extension DogProfileDTO { 18 | static var example: DogProfileDTO = DogProfileDTO(id: UUID(uuidString: "4a8c392f-24af-4fbf-9043-11b4dd4c131b") ?? .init(), 19 | name: "두식", 20 | keywords: [.energetic, .friendly], 21 | profileImage: nil) 22 | } 23 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/DTO/MPCProfileDropDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MateRequest.swift 3 | // SniffMeet 4 | // 5 | // Created by 배현진 on 11/20/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct MPCProfileDropDTO: Codable { 11 | let token: Data? 12 | let profile: DogProfileDTO? 13 | let transitionMessage: String? 14 | } 15 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/DTO/MateListDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MateList.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/28/24. 6 | // 7 | import Foundation 8 | 9 | /// MateList를 insert할 때 쓰이는 DTO입니다. 10 | /// 처음 회원가입할 때는 메이트가 없으므로 빈 배열로 고정했습니다. 11 | struct MateListInsertDTO: Encodable { 12 | let id: UUID 13 | let mates: [UUID] = [ 14 | UUID(uuidString: "f27c02f6-0110-4291-b866-a1ead0742755") ?? .init(), 15 | UUID(uuidString: "b79bc6b9-b776-4f5b-8f6c-48ba498b6e3a") ?? .init(), 16 | UUID(uuidString: "bda7ec28-1407-4871-93ea-c7835986726a") ?? .init(), 17 | UUID(uuidString: "a96ee934-03b9-43f3-b29b-53c3ba945363") ?? .init() 18 | ] 19 | enum CodingKeys: String, CodingKey { 20 | case id, mates 21 | } 22 | } 23 | 24 | struct MateListDTO: Codable { 25 | let mates: [UUID] 26 | } 27 | 28 | struct MateListRequestDTO: Encodable { 29 | let userId: UUID 30 | 31 | enum CodingKeys: String, CodingKey { 32 | case userId = "user_id" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/DTO/NotiListDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotiListDTO.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/30/24. 6 | // 7 | import Foundation 8 | 9 | /// 회원가입 할 때, 빈 배열로 insert할 때 사용하는 DTO 10 | struct WalkNotiListInsertDTO: Encodable { 11 | let id: UUID 12 | let notifications: [UUID] = [] 13 | enum CodingKeys: String, CodingKey { 14 | case id, notifications 15 | } 16 | } 17 | 18 | /// 노티 리스트를 요청할 때, body에 보내야하는 DTO 19 | struct WalkNotiListRequestDTO: Encodable { 20 | let userId: UUID 21 | 22 | enum CodingKeys: String, CodingKey { 23 | case userId = "p_id" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/DTO/SaveDeviceTokenDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SaveDeviceTokenDTO.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 12/1/24. 6 | // 7 | 8 | struct SaveDeviceTokenDTO: Codable { 9 | let deviceToken: String 10 | 11 | enum CodingKeys: String, CodingKey { 12 | case deviceToken = "device_token" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/DTO/UserInfoDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserInfo.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/27/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Supabase 서버와 주고받을 유저의 정보입니다. DTO로 사용할 수 있습니다. 11 | /// id: Supabase DB Table의 row에 접근한 권한이 있는지 확인하려면(RLS) id가 필요합니다. 12 | /// profileImageURL: 프로필 이미지의 이름입니다. 13 | struct UserInfoDTO: Codable { 14 | let id: UUID 15 | let dogName: String 16 | let age: UInt8 17 | let sex: Sex 18 | let sexUponIntake: Bool 19 | let size: Size 20 | let keywords: [Keyword] 21 | let nickname: String 22 | let profileImageURL: String? 23 | 24 | enum CodingKeys: String, CodingKey { 25 | case id, age, sex, size, keywords, nickname 26 | case dogName = "dog_name" 27 | case sexUponIntake = "sex_upon_intake" 28 | case profileImageURL = "profile_image_url" 29 | } 30 | func toEntity() -> UserInfo { 31 | UserInfo(name: dogName, 32 | age: age, 33 | sex: sex, 34 | sexUponIntake: sexUponIntake, 35 | size: size, 36 | keywords: keywords, 37 | nickname: nickname) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/DTO/WalkAPSDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WalkAPSDTO.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/28/24. 6 | // 7 | 8 | struct WalkAPSDTO: Decodable { 9 | let aps: APS 10 | let notification: WalkNotiDTO 11 | } 12 | 13 | struct APS: Decodable { 14 | let alert: Alert 15 | } 16 | 17 | struct Alert: Decodable { 18 | let title: String 19 | let subtitle: String 20 | } 21 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/DTO/WalkNotiDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WalkNotiDTO.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/27/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct WalkNotiDTO: Codable { 11 | let id: UUID 12 | let createdAt: String 13 | let message: String 14 | let latitude: Double 15 | let longtitude: Double 16 | let senderId: UUID 17 | let receiverId: UUID 18 | let senderName: String 19 | let category: WalkNotiCategory 20 | 21 | enum CodingKeys: String, CodingKey { 22 | case id 23 | case createdAt = "created_at" 24 | case message, latitude, longtitude 25 | case senderId = "sender" 26 | case receiverId = "receiver" 27 | case senderName = "name" 28 | case category 29 | } 30 | 31 | var createdAtDate: Date? { 32 | let dateFormatter = ISO8601DateFormatter() 33 | return dateFormatter.date(from: createdAt) 34 | } 35 | 36 | func toEntity() -> WalkNoti { 37 | WalkNoti(id: id, 38 | createdAt: createdAtDate, 39 | message: message, 40 | latitude: latitude, 41 | longtitude: longtitude, 42 | senderId: senderId, 43 | senderName: senderName, 44 | category: category) 45 | } 46 | } 47 | 48 | extension WalkNotiDTO { 49 | static let example = WalkNotiDTO(id: UUID(), 50 | createdAt: Date().convertDateToISO8601String(), 51 | message: "You have a new notification.", 52 | latitude: 37.7749, 53 | longtitude: -122.4194, 54 | senderId: UUID(), 55 | receiverId: UUID(), 56 | senderName: "두두3ㅜ두두두두식", 57 | category: WalkNotiCategory.walkRequest) 58 | } 59 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/DTO/WalkRequestDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WalkRequest.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 12/2/24. 6 | // 7 | import Foundation 8 | 9 | struct WalkRequestInsertDTO: Encodable { 10 | let id: UUID 11 | let createdAt: String 12 | let sender: UUID 13 | let receiver: UUID 14 | let message: String 15 | let latitude: Double 16 | let longitude: Double 17 | let state: WalkRequestState 18 | 19 | enum CodingKeys: String, CodingKey { 20 | case id 21 | case createdAt = "created_at" 22 | case sender, receiver, message, latitude, longitude, state 23 | } 24 | } 25 | 26 | struct WalkRequestUpdateDTO: Encodable { 27 | let state: WalkRequestState 28 | } 29 | 30 | enum WalkRequestState: String, Encodable { 31 | case pending 32 | case accepted 33 | case declined 34 | } 35 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Entity/DogTraits/Keyword.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Keyword.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/28/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Keyword: String, Codable { 11 | case energetic = "활발한" 12 | case smart = "똑똑한" 13 | case friendly = "친화력 좋은" 14 | case shy = "소심한" 15 | case independent = "독립적인" 16 | } 17 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Entity/DogTraits/Sex.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sex.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/28/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Sex: String, Codable { 11 | case male = "남" 12 | case female = "여" 13 | } 14 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Entity/DogTraits/Size.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Size.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/28/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Size: String, Codable { 11 | case small = "소형" 12 | case medium = "중형" 13 | case big = "대형" 14 | } 15 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Entity/ImageCacheable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Untitled.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 12/5/24. 6 | // 7 | import Foundation 8 | 9 | final class CacheableImage: Codable { 10 | let lastModified: String 11 | let imageData: Data 12 | 13 | init(lastModified: String, imageData: Data) { 14 | self.lastModified = lastModified 15 | self.imageData = imageData 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Entity/Mate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Mate.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/24/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Mate { 11 | var name: String 12 | var userID: UUID 13 | var keywords: [Keyword] 14 | var profileImageURLString: String? 15 | } 16 | 17 | extension Mate { 18 | static let example: Mate = Mate( 19 | name: "후추", 20 | userID: UUID(), 21 | keywords: [.shy], 22 | profileImageURLString: nil 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Entity/OnBoardingPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnBoardingPage.swift 3 | // SniffMeet 4 | // 5 | // Created by 배현진 on 12/4/24. 6 | // 7 | 8 | struct OnBoardingPage { 9 | let title: String 10 | let description: String 11 | let imageName: String 12 | let isGif: Bool 13 | } 14 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Entity/UserInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DogDTO.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/14/24. 6 | // 7 | import Foundation 8 | 9 | struct DogInfo { 10 | let name: String 11 | let age: UInt8 12 | let sex: Sex 13 | let sexUponIntake: Bool 14 | let size: Size 15 | let keywords: [Keyword] 16 | } 17 | 18 | struct UserInfo: Codable { 19 | let name: String 20 | let age: UInt8 21 | let sex: Sex 22 | let sexUponIntake: Bool 23 | let size: Size 24 | let keywords: [Keyword] 25 | let nickname: String 26 | var profileImage: Data? 27 | } 28 | 29 | extension UserInfo { 30 | static let example: UserInfo = UserInfo(name: "후추", 31 | age: 6, 32 | sex: .female, 33 | sexUponIntake: true, 34 | size: .medium, 35 | keywords: [.shy], 36 | nickname: "pear", 37 | profileImage: nil) 38 | } 39 | 40 | extension DogInfo { 41 | static let example = DogInfo(name: "후추", 42 | age: 6, 43 | sex: .female, 44 | sexUponIntake: true, 45 | size: .medium, 46 | keywords: [.shy]) 47 | } 48 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Entity/WalkLog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WalkLog.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/24/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct WalkLog { 11 | let step: Int 12 | let distance: Double 13 | let startDate: Date 14 | let endDate: Date 15 | let image: Data? 16 | 17 | var duration: TimeInterval { 18 | endDate.timeIntervalSince(startDate) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Entity/WalkNoti.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WalkNoti.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/20/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct WalkRequestDetail: Codable { 11 | let mate: UserInfo 12 | let address: Address 13 | let message: String 14 | } 15 | 16 | struct Address: Codable { 17 | let longtitude: Double 18 | let latitude: Double 19 | let location: String 20 | 21 | init(longtitude: Double, latitude: Double) { 22 | self.longtitude = longtitude 23 | self.latitude = latitude 24 | // TODO: - 경도와 위도를 가지고 주소를 변환 필요 25 | location = "" 26 | } 27 | init(longtitude: Double, latitude: Double, location: String) { 28 | self.longtitude = longtitude 29 | self.latitude = latitude 30 | self.location = location 31 | } 32 | } 33 | 34 | extension Address { 35 | static let example: Address = Address(longtitude: 12.0, 36 | latitude: 12.0, 37 | location: "서울 코드스쿼드") 38 | } 39 | 40 | struct WalkNoti { 41 | let id: UUID 42 | let createdAt: Date? 43 | let message: String 44 | let latitude: Double 45 | let longtitude: Double 46 | let senderId: UUID 47 | let senderName: String 48 | let category: WalkNotiCategory 49 | } 50 | extension WalkNoti { 51 | static let example = WalkNoti(id: UUID(), 52 | createdAt: Date.now, 53 | message: "산책하시죠", 54 | latitude: 11, 55 | longtitude: 11, 56 | senderId: UUID(), 57 | senderName: "지성", 58 | category: .walkRequest) 59 | } 60 | 61 | enum WalkNotiCategory: String, Codable { 62 | case walkRequest 63 | case walkAccepted 64 | case walkDeclined 65 | 66 | var label: String { 67 | switch self { 68 | case .walkRequest: "산책 요청" 69 | case .walkAccepted: "산책 수락" 70 | case .walkDeclined: "산책 거절" 71 | } 72 | } 73 | 74 | var description: String { 75 | switch self { 76 | case .walkRequest: "님이 산책 요청을 요청했어요!" 77 | case .walkAccepted: "님이 산책을 수락했어요!" 78 | case .walkDeclined: "님이 산책을 거절했어요..." 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Home/Alarm/Interactor/NotificationListInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationListInteractor.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 12/1/24. 6 | // 7 | 8 | protocol NotificationListInteractable: AnyObject { 9 | var presenter: (any NotificationListInteractorOutput)? { get set } 10 | func fetchNotificationList() 11 | } 12 | 13 | final class NotificationListInteractor: NotificationListInteractable { 14 | weak var presenter: (any NotificationListInteractorOutput)? 15 | private let requestNotiListUseCase: (any RequestNotiListUseCase) 16 | 17 | init( 18 | presenter: (any NotificationListInteractorOutput)? = nil, 19 | requestNotiListUseCase: any RequestNotiListUseCase 20 | ) { 21 | self.presenter = presenter 22 | self.requestNotiListUseCase = requestNotiListUseCase 23 | } 24 | 25 | func fetchNotificationList() { 26 | Task { 27 | let notiList = await requestNotiListUseCase.execute() 28 | presenter?.didFetchNotificationList(with: notiList) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Home/Alarm/Presenter/NotificationListPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationListPresenter.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 12/1/24. 6 | // 7 | 8 | import Combine 9 | 10 | protocol NotificationListPresentable: AnyObject { 11 | var view: (any NotificationListViewable)? { get set } 12 | var interactor: (any NotificationListInteractable)? { get set } 13 | var router: (any NotificationListRoutable)? { get set } 14 | 15 | var output: any NotificationListPresenterOutput { get } 16 | func viewDidLoad() 17 | func didTapNotificationCell(index: Int) 18 | func didDeleteNotificationCell(index: Int) 19 | func didTapDismissButton() 20 | } 21 | 22 | protocol NotificationListInteractorOutput: AnyObject { 23 | func didFetchNotificationList(with: [WalkNoti]) 24 | } 25 | 26 | final class NotificationListPresenter: NotificationListPresentable { 27 | weak var view: (any NotificationListViewable)? 28 | var interactor: (any NotificationListInteractable)? 29 | var router: (any NotificationListRoutable)? 30 | var output: any NotificationListPresenterOutput 31 | 32 | init( 33 | view: (any NotificationListViewable)? = nil, 34 | interactor: (any NotificationListInteractable)? = nil, 35 | router: (any NotificationListRoutable)? = nil, 36 | output: any NotificationListPresenterOutput 37 | ) { 38 | self.view = view 39 | self.interactor = interactor 40 | self.router = router 41 | self.output = output 42 | } 43 | 44 | func viewDidLoad() { 45 | interactor?.fetchNotificationList() 46 | } 47 | func didTapNotificationCell(index: Int) { 48 | guard let view else { return } 49 | router?.showWalkNotification(view: view, walkNoti: output.notificationList.value[index]) 50 | } 51 | func didDeleteNotificationCell(index: Int) { 52 | var notiList = output.notificationList.value 53 | notiList.remove(at: index) 54 | output.notificationList.send(notiList) 55 | // TODO: 서버에도 반영 필요 56 | } 57 | func didTapDismissButton() { 58 | guard let view else { return } 59 | router?.dismiss(view: view) 60 | } 61 | } 62 | 63 | // MARK: - NotificationListPresenter+NotficationListInteractorOutput 64 | 65 | extension NotificationListPresenter: NotificationListInteractorOutput { 66 | func didFetchNotificationList(with notificationList: [WalkNoti]) { 67 | output.notificationList.send(notificationList) 68 | } 69 | } 70 | 71 | // MARK: - NotificationListPresenterOutput 72 | 73 | protocol NotificationListPresenterOutput { 74 | var notificationList: CurrentValueSubject<[WalkNoti], Never> { get } 75 | } 76 | 77 | struct DefaultNotificationListPresenterOutput: NotificationListPresenterOutput { 78 | var notificationList: CurrentValueSubject<[WalkNoti], Never> 79 | } 80 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Home/Alarm/View/NotificationCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationCell.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/24/24. 6 | // 7 | 8 | import UIKit 9 | 10 | final class NotificationCell: UITableViewCell { 11 | private let notificationStackView: UIStackView = UIStackView() 12 | private let sectionLabel: UILabel = UILabel() 13 | private let descriptionLabel: UILabel = UILabel() 14 | private let dateLabel: UILabel = UILabel() 15 | 16 | override init( 17 | style: UITableViewCell.CellStyle, 18 | reuseIdentifier: String? 19 | ) { 20 | super.init(style: style, reuseIdentifier: reuseIdentifier) 21 | configureAttributes() 22 | configureHierarchy() 23 | configureConstraints() 24 | } 25 | 26 | @available(*, unavailable) 27 | required init?(coder: NSCoder) { 28 | fatalError("init(coder:) has not been implemented") 29 | } 30 | 31 | private func configureAttributes() { 32 | notificationStackView.axis = .vertical 33 | notificationStackView.spacing = 2 34 | notificationStackView.isLayoutMarginsRelativeArrangement = true 35 | notificationStackView.layoutMargins = UIEdgeInsets( 36 | top: LayoutConstant.xsmallVerticalPadding, 37 | left: LayoutConstant.horizontalPadding, 38 | bottom: LayoutConstant.xsmallVerticalPadding, 39 | right: LayoutConstant.horizontalPadding 40 | ) 41 | sectionLabel.font = SNMFont.caption 42 | sectionLabel.textColor = SNMColor.text3 43 | dateLabel.font = SNMFont.caption 44 | dateLabel.textColor = SNMColor.text3 45 | } 46 | private func configureHierarchy() { 47 | [notificationStackView].forEach { 48 | $0.translatesAutoresizingMaskIntoConstraints = false 49 | contentView.addSubview($0) 50 | } 51 | 52 | [sectionLabel, 53 | descriptionLabel, 54 | dateLabel].forEach { 55 | $0.translatesAutoresizingMaskIntoConstraints = false 56 | notificationStackView.addArrangedSubview($0) 57 | } 58 | } 59 | private func configureConstraints() { 60 | NSLayoutConstraint.activate([ 61 | notificationStackView.topAnchor.constraint(equalTo: contentView.topAnchor), 62 | notificationStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), 63 | notificationStackView.leadingAnchor.constraint( 64 | equalTo: contentView.leadingAnchor 65 | ), 66 | notificationStackView.trailingAnchor.constraint( 67 | equalTo: contentView.trailingAnchor 68 | ) 69 | ]) 70 | } 71 | 72 | func configure(section: String, description: String, dateString: String) { 73 | sectionLabel.text = section 74 | descriptionLabel.text = description 75 | dateLabel.text = dateString 76 | } 77 | } 78 | 79 | extension NotificationCell { 80 | static let identifier: String = String(describing: NotificationCell.self) 81 | } 82 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Home/EditProfile/Router/ProfileEditRoutable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileEditRoutable.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 12/1/24. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol ProfileEditRoutable: Routable { 11 | func presentMainScreen(from view: ProfileEditViewable) 12 | } 13 | 14 | protocol ProfileEditBuildable { 15 | static func createProfileEditModule(userInfo: UserInfo) -> UIViewController 16 | } 17 | 18 | final class ProfileEditRouter: ProfileEditRoutable { 19 | func presentMainScreen(from view: any ProfileEditViewable) { 20 | Task { @MainActor in 21 | pop(from: view as! UIViewController, animated: true) 22 | } 23 | } 24 | } 25 | 26 | extension ProfileEditRouter: ProfileEditBuildable { 27 | static func createProfileEditModule(userInfo: UserInfo) -> UIViewController { 28 | let saveUserInfoUseCase: SaveUserInfoUseCase = SaveUserInfoUseCaseImpl( 29 | localDataManager: LocalDataManager(), 30 | imageManager: SNMFileManager() 31 | ) 32 | let updateUserInfoRemoteUseCase: UpdateUserInfoUseCase = UpdateUserInfoUseCaseImpl() 33 | let saveProfileImageUseCase: SaveProfileImageUseCase = SaveProfileImageUseCaseImpl( 34 | remoteImageManager: SupabaseStorageManager( 35 | networkProvider: SNMNetworkProvider() 36 | ), 37 | userDefaultsManager: UserDefaultsManager.shared 38 | ) 39 | let view: ProfileEditViewable & UIViewController = ProfileEditViewController() 40 | let router: ProfileEditRoutable & ProfileEditBuildable = ProfileEditRouter() 41 | let interactor: ProfileEditInteractable = ProfileEditInteractor( 42 | saveUserInfoUseCase: saveUserInfoUseCase, 43 | updateUserInfoRemoteUseCase: updateUserInfoRemoteUseCase, 44 | saveProfileImageUseCase: saveProfileImageUseCase, 45 | loadUserInfoUseCase: LoadUserInfoUseCaseImpl( 46 | dataLoadable: LocalDataManager(), 47 | imageManageable: SNMFileManager() 48 | ) 49 | ) 50 | let presenter: ProfileEditPresentable & ProfileEditInteractorOutput = 51 | ProfileEditPresenter(userInfo: userInfo) 52 | 53 | view.presenter = presenter 54 | presenter.view = view 55 | presenter.router = router 56 | presenter.interactor = interactor 57 | interactor.presenter = presenter 58 | 59 | return view 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Home/Main/HomeModuleBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeModuleBuilder.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/11/24. 6 | // 7 | 8 | import UIKit 9 | 10 | enum HomeModuleBuilder { 11 | static func build() -> UIViewController { 12 | let view = HomeViewController() 13 | let router = HomeRouter() 14 | let interactor = HomeInteractor( 15 | loadUserInfoUseCase: LoadUserInfoUseCaseImpl( 16 | dataLoadable: LocalDataManager(), 17 | imageManageable: SNMFileManager() 18 | ), 19 | checkFirstLaunchUseCase: CheckFirstLaunchUseCaseImpl( 20 | userDefaultsManager: UserDefaultsManager.shared 21 | ), 22 | saveFirstLaunchUseCase: SaveFirstLaunchUseCaseImpl( 23 | userDefaultsManager: UserDefaultsManager.shared 24 | ), 25 | requestNotificationAuthUseCase: RequestNotificationAuthUseCaseImpl(), 26 | remoteSaveDeviceTokenUseCase: RemoteSaveDeviceTokenUseCaseImpl( 27 | jsonEncoder: JSONEncoder(), 28 | keychainManager: KeychainManager.shared, 29 | remoteDBManager: SupabaseDatabaseManager.shared 30 | ) 31 | ) 32 | view.presenter = HomePresenter(view: view, router: router, interactor: interactor) 33 | interactor.presenter = view.presenter 34 | return view 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Home/Main/Interactor/HomeInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeInteractor.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/18/24. 6 | // 7 | 8 | protocol HomeInteractable: AnyObject { 9 | var presenter: (any HomePresentable)? { get } 10 | func loadInfo() throws -> UserInfo 11 | func saveDeviceToken() 12 | } 13 | 14 | final class HomeInteractor: HomeInteractable { 15 | weak var presenter: (any HomePresentable)? 16 | private let loadUserInfoUseCase: any LoadUserInfoUseCase 17 | private let checkFirstLaunchUseCase: any CheckFirstLaunchUseCase 18 | private let saveFirstLaunchUseCase: any SaveFirstLaunchUseCase 19 | private let requestNotificationAuthUseCase: any RequestNotificationAuthUseCase 20 | private let remoteSaveDeviceTokenUseCase: any RemoteSaveDeviceTokenUseCase 21 | 22 | init( 23 | presenter: (any HomePresentable)? = nil, 24 | loadUserInfoUseCase: any LoadUserInfoUseCase, 25 | checkFirstLaunchUseCase: any CheckFirstLaunchUseCase, 26 | saveFirstLaunchUseCase: any SaveFirstLaunchUseCase, 27 | requestNotificationAuthUseCase: any RequestNotificationAuthUseCase, 28 | remoteSaveDeviceTokenUseCase: any RemoteSaveDeviceTokenUseCase 29 | ) { 30 | self.presenter = presenter 31 | self.loadUserInfoUseCase = loadUserInfoUseCase 32 | self.checkFirstLaunchUseCase = checkFirstLaunchUseCase 33 | self.saveFirstLaunchUseCase = saveFirstLaunchUseCase 34 | self.requestNotificationAuthUseCase = requestNotificationAuthUseCase 35 | self.remoteSaveDeviceTokenUseCase = remoteSaveDeviceTokenUseCase 36 | } 37 | 38 | func loadInfo() throws -> UserInfo { 39 | try loadUserInfoUseCase.execute() 40 | } 41 | func saveDeviceToken() { 42 | guard checkFirstLaunchUseCase.execute() else { return } 43 | Task { 44 | do { 45 | try saveFirstLaunchUseCase.execute() 46 | _ = try await requestNotificationAuthUseCase.execute() 47 | try await remoteSaveDeviceTokenUseCase.execute() 48 | } catch { 49 | SNMLogger.error(error.localizedDescription) 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Home/Main/Presenter/HomePresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomePresenter.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/18/24. 6 | // 7 | 8 | import Combine 9 | import UIKit 10 | 11 | protocol HomePresentable: AnyObject { 12 | var view: (any HomeViewable)? { get set } 13 | var router: (any HomeRoutable)? { get set } 14 | var interactor: (any HomeInteractable)? { get set } 15 | var output: (any HomePresenterOutput) { get } 16 | 17 | func viewDidLoad() 18 | func notificationBarButtonDidTap() 19 | func didTapEditButton(userInfo: UserInfo) 20 | func didTapRequestWalkButton() 21 | } 22 | 23 | final class HomePresenter: HomePresentable { 24 | weak var view: (any HomeViewable)? 25 | var router: (any HomeRoutable)? 26 | var interactor: (any HomeInteractable)? 27 | var output: HomePresenterOutput 28 | 29 | init( 30 | view: (any HomeViewable)? = nil, 31 | router: (any HomeRoutable)? = nil, 32 | interactor: (any HomeInteractable)? = nil, 33 | output: HomePresenterOutput = DefaultHomePresenterOutput( 34 | dogInfo: CurrentValueSubject(UserInfo.example) 35 | ) 36 | ) { 37 | self.view = view 38 | self.router = router 39 | self.interactor = interactor 40 | self.output = output 41 | } 42 | 43 | func viewDidLoad() { 44 | interactor?.saveDeviceToken() 45 | do { 46 | if let dog = try interactor?.loadInfo() { 47 | output.dogInfo.send(dog) 48 | } 49 | } catch { 50 | SNMLogger.error("이미지 실패?: \(error.localizedDescription)") 51 | let placeHolderInfo: UserInfo = UserInfo.example 52 | output.dogInfo.send(placeHolderInfo) 53 | } 54 | } 55 | 56 | func notificationBarButtonDidTap() { 57 | guard let view else { return } 58 | router?.showNotificationView(homeView: view) 59 | } 60 | 61 | func didTapEditButton(userInfo: UserInfo) { 62 | guard let view else { return } 63 | router?.showProfileEditView(homeView: view, userInfo: userInfo) 64 | } 65 | 66 | func didTapRequestWalkButton() { 67 | guard let view else { return } 68 | router?.transitionToMateListView(homeView: view) 69 | } 70 | } 71 | 72 | // MARK: - HomePresenterOutput 73 | 74 | protocol HomePresenterOutput { 75 | var dogInfo: CurrentValueSubject { get } 76 | } 77 | 78 | struct DefaultHomePresenterOutput: HomePresenterOutput { 79 | var dogInfo: CurrentValueSubject 80 | } 81 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Home/Main/Router/HomeRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeRouter.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/18/24. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol HomeRoutable: Routable { 11 | func showProfileEditView(homeView: any HomeViewable, userInfo: UserInfo) 12 | func showNotificationView(homeView: any HomeViewable) 13 | func showAlert(homeView: any HomeViewable, title: String, message: String) 14 | func transitionToMateListView(homeView: any HomeViewable) 15 | } 16 | 17 | final class HomeRouter: NSObject, HomeRoutable { 18 | func showProfileEditView(homeView: any HomeViewable, userInfo: UserInfo) { 19 | guard let homeView = homeView as? UIViewController else { return } 20 | let profileCreateViewController = 21 | ProfileEditRouter.createProfileEditModule(userInfo: userInfo) 22 | pushNoBottomBar(from: homeView, to: profileCreateViewController, animated: true) 23 | } 24 | func showNotificationView(homeView: any HomeViewable) { 25 | guard let homeView = homeView as? UIViewController else { return } 26 | let notificationViewController = NotificationListRouter.createNotificationListModule() 27 | push(from: homeView, to: notificationViewController, animated: true) 28 | } 29 | func showAlert(homeView: any HomeViewable, title: String, message: String) { 30 | guard let homeView = homeView as? UIViewController else { return } 31 | if let presentedVC = homeView.presentedViewController as? UIAlertController { 32 | presentedVC.dismiss(animated: false) 33 | } 34 | 35 | let alertVC = UIAlertController(title: title, message: message, preferredStyle: .alert) 36 | alertVC.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) 37 | homeView.present(alertVC, animated: true, completion: nil) 38 | } 39 | func transitionToMateListView(homeView: any HomeViewable) { 40 | guard let homeView = homeView as? UIViewController, 41 | let tabBarController = homeView.tabBarController else { return } 42 | tabBarController.selectedIndex = 1 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Home/Main/Router/PresentAnimator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PresentAnimator.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/27/24. 6 | // 7 | import UIKit 8 | 9 | 10 | final class FromTop2BottomPresentAnimator: NSObject, UIViewControllerAnimatedTransitioning { 11 | func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { 12 | return 0.4 13 | } 14 | 15 | func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { 16 | guard let toView = transitionContext.view(forKey: .to) else { return } 17 | toView.transform = CGAffineTransform(translationX: 0, y: -transitionContext.containerView.bounds.height) 18 | 19 | transitionContext.containerView.addSubview(toView) 20 | UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: { 21 | toView.transform = .identity 22 | }) { _ in 23 | transitionContext.completeTransition(!transitionContext.transitionWasCancelled) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Mate/MateList/Interactor/MateListInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MateListPresentable.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/21/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol MateListInteractable: AnyObject { 11 | var presenter: MateListInteractorOutput? { get set } 12 | 13 | func requestMateList(userID: UUID) 14 | func requestProfileImage(id: UUID, imageName: String?) 15 | } 16 | 17 | final class MateListInteractor: MateListInteractable { 18 | weak var presenter: (any MateListInteractorOutput)? 19 | private let requestMateListUseCase: any RequestMateListUseCase 20 | private let requestProfileImageUseCase: any RequestProfileImageUseCase 21 | init( 22 | presenter: (any MateListInteractorOutput)? = nil, 23 | requestMateListUseCase: any RequestMateListUseCase, 24 | requestProfileImageUseCase: any RequestProfileImageUseCase 25 | 26 | ) { 27 | self.presenter = presenter 28 | self.requestMateListUseCase = requestMateListUseCase 29 | self.requestProfileImageUseCase = requestProfileImageUseCase 30 | } 31 | 32 | func requestMateList(userID: UUID) { 33 | Task { @MainActor in 34 | let mateList = await requestMateListUseCase.execute() 35 | presenter?.didFetchMateList(mateList: mateList) 36 | } 37 | } 38 | 39 | func requestProfileImage(id: UUID, imageName: String?) { 40 | Task { @MainActor in 41 | let imageData = try await requestProfileImageUseCase.execute(fileName: imageName ?? "") 42 | presenter?.didFetchProfileImage(id: id, imageData: imageData) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Mate/MateList/View/AddMateButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrimaryButton.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/7/24. 6 | // 7 | 8 | import Combine 9 | import UIKit 10 | 11 | final class AddMateButton: UIButton { 12 | enum ButtonState: String { 13 | case normal 14 | case connecting 15 | case success 16 | case failure 17 | } 18 | 19 | var buttonState: ButtonState = .normal { 20 | didSet { 21 | switch buttonState { 22 | case .normal: 23 | configuration?.background.backgroundColor = SNMColor.mainNavy 24 | configuration?.title = "새 메이트를 연결하세요" 25 | configuration?.image = UIImage(systemName: "plus.magnifyingglass") 26 | case .connecting: 27 | configuration?.background.backgroundColor = SNMColor.disabledGray 28 | configuration?.title = "연결 중..." 29 | configuration?.image = UIImage(systemName: "wifi") 30 | case .success: 31 | configuration?.background.backgroundColor = SNMColor.mainBrown 32 | configuration?.title = "연결 성공, 프로필드랍 시도하세요" 33 | configuration?.image = UIImage(systemName: "checkmark.circle") 34 | case .failure: 35 | configuration?.background.backgroundColor = SNMColor.disabledGray 36 | configuration?.title = "연결 실패" 37 | configuration?.image = UIImage(systemName: "x.circle") 38 | } 39 | } 40 | } 41 | 42 | init(title: String) { 43 | super.init(frame: .zero) 44 | setupConfiguration(title: title) 45 | } 46 | 47 | @available(*, unavailable) 48 | required init?(coder: NSCoder) { 49 | super.init(coder: coder) 50 | } 51 | 52 | private func setupConfiguration(title: String) { 53 | var configuration = UIButton.Configuration.filled() 54 | configuration.title = title 55 | configuration.image = UIImage(systemName: "plus.magnifyingglass") 56 | configuration.imagePlacement = .trailing 57 | configuration.imagePadding = 4 58 | configuration.baseBackgroundColor = SNMColor.mainNavy 59 | configuration.baseForegroundColor = SNMColor.white 60 | configuration.cornerStyle = .capsule 61 | configuration.contentInsets = NSDirectionalEdgeInsets( 62 | top: 20, 63 | leading: 16, 64 | bottom: 20, 65 | trailing: 16 66 | ) 67 | configuration.attributedTitle = AttributedString( 68 | title, 69 | attributes: AttributeContainer( 70 | [.font: UIFont.systemFont(ofSize: 16.0, weight: .bold)] 71 | ) 72 | ) 73 | self.configuration = configuration 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Mate/RequestMate/Interactor/RequestMateInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestMateInteractor.swift 3 | // SniffMeet 4 | // 5 | // Created by 배현진 on 11/20/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol RequestMateInteractable: AnyObject { 11 | var presenter: (any RequestMateInteractorOutput)? { get set } 12 | 13 | func fetchDogProfile() 14 | func saveMateInfo(id: UUID) async 15 | } 16 | 17 | final class RequestMateInteractor: RequestMateInteractable { 18 | weak var presenter: (any RequestMateInteractorOutput)? 19 | private let respondMateRequestUseCase: RespondMateRequestUseCase 20 | 21 | init( 22 | presenter: (any RequestMateInteractorOutput)? = nil, 23 | respondMateRequestUseCase: RespondMateRequestUseCase 24 | ) { 25 | self.presenter = presenter 26 | self.respondMateRequestUseCase = respondMateRequestUseCase 27 | } 28 | 29 | func fetchDogProfile() { 30 | /// 프로필 데이터 가져오고 presenter의 didFetchDogProfile 호출. 31 | } 32 | func saveMateInfo(id: UUID) async { 33 | await respondMateRequestUseCase.execute(mateId: id, isAccepted: true) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Mate/RequestMate/Presenter/RequestMatePresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestMatePresenter.swift 3 | // SniffMeet 4 | // 5 | // Created by 배현진 on 11/20/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol RequestMatePresentable: AnyObject { 11 | var view: RequestMateViewable? { get set } 12 | var interactor: RequestMateInteractable? { get set } 13 | var router: RequestMateRoutable? { get set } 14 | 15 | func closeTheView() 16 | func didTapAcceptButton(id: UUID) async 17 | } 18 | 19 | protocol RequestMateInteractorOutput: AnyObject { 20 | 21 | } 22 | 23 | final class RequestMatePresenter: RequestMatePresentable { 24 | weak var view: RequestMateViewable? 25 | var interactor: RequestMateInteractable? 26 | var router: RequestMateRoutable? 27 | 28 | init( 29 | view: RequestMateViewable? = nil, 30 | interactor: RequestMateInteractable? = nil, 31 | router: RequestMateRoutable? = nil 32 | ) { 33 | self.view = view 34 | self.interactor = interactor 35 | self.router = router 36 | } 37 | 38 | func closeTheView() { 39 | if let view { 40 | router?.dismissView(view: view) 41 | } 42 | } 43 | func didTapAcceptButton(id: UUID) async { 44 | SNMLogger.info("id: \(id)") 45 | await interactor?.saveMateInfo(id: id) 46 | } 47 | } 48 | 49 | extension RequestMatePresenter: RequestMateInteractorOutput { 50 | 51 | } 52 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Mate/RequestMate/Router/RequestMateRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestMateRouter.swift 3 | // SniffMeet 4 | // 5 | // Created by 배현진 on 11/20/24. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol RequestMateRoutable: AnyObject { 11 | var presenter: (any RequestWalkPresentable)? { get set } 12 | 13 | func dismissView(view: any RequestMateViewable) 14 | } 15 | 16 | protocol RequestMateBuildable { 17 | static func createRequestMateModule(profile: DogProfileDTO) -> UIViewController 18 | } 19 | 20 | final class RequestMateRouter: RequestMateRoutable { 21 | weak var presenter: (any RequestWalkPresentable)? 22 | 23 | func dismissView(view: any RequestMateViewable) { 24 | if let view = view as? UIViewController { 25 | SNMLogger.log("dismissView") 26 | view.dismiss(animated: true) 27 | } 28 | } 29 | } 30 | 31 | extension RequestMateRouter: RequestMateBuildable { 32 | static func createRequestMateModule(profile: DogProfileDTO) -> UIViewController { 33 | let respondMateRequestUseCase: RespondMateRequestUseCase = RespondMateRequestUseCaseImpl( 34 | localDataManager: LocalDataManager(), 35 | remoteDataManger: SupabaseDatabaseManager.shared) 36 | let view = RequestMateViewController(profile: profile) 37 | let presenter: RequestMatePresentable & RequestMateInteractorOutput = RequestMatePresenter() 38 | let interactor = RequestMateInteractor(respondMateRequestUseCase: respondMateRequestUseCase) 39 | let router: RequestMateRoutable & RequestMateBuildable = RequestMateRouter() 40 | 41 | view.presenter = presenter 42 | presenter.view = view 43 | presenter.interactor = interactor 44 | presenter.router = router 45 | interactor.presenter = presenter 46 | 47 | return view 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/OnBoarding/MainBoarding/Interactor/OnBoardingInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnBoardingInteractor.swift 3 | // SniffMeet 4 | // 5 | // Created by 배현진 on 12/4/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol OnBoardingInteractable: AnyObject { 11 | var presenter: (any OnBoardingPresentable)? { get set } 12 | 13 | func fetchPages() -> [OnBoardingPage] 14 | } 15 | 16 | final class OnBoardingInteractor: OnBoardingInteractable { 17 | weak var presenter: (any OnBoardingPresentable)? 18 | 19 | init( 20 | presenter: (any OnBoardingPresentable)? = nil 21 | ) { 22 | self.presenter = presenter 23 | } 24 | 25 | private let pages = [ 26 | OnBoardingPage( 27 | title: "직접적인\n연락처 공유없이\n산책 메이트를 만들고\n언제든 산책 요청 보내기", 28 | description: "SniffMEET에서 가능해요!", 29 | imageName: "AppImage", 30 | isGif: false 31 | ), 32 | OnBoardingPage( 33 | title: "프로필 드랍", 34 | description: "새로운 메이트를 추가하고 싶다면\n메이트 목록에서 친구만들기 버튼을 누르고\n위에처럼 움직이며 프로필 드랍을 이용하세요.", 35 | imageName: "ProfileDrop", 36 | isGif: true 37 | ), 38 | OnBoardingPage( 39 | title: "산책 요청", 40 | description: "관계가 형성된 메이트들에게\n자유롭게 산책 요청을 보내고 받아보세요.\n만날 장소를 지정해 요청을 보낼 수 있어요.", 41 | imageName: "WalkingDog", 42 | isGif: false 43 | ) 44 | ] 45 | 46 | func fetchPages() -> [OnBoardingPage] { 47 | SNMLogger.log("interactor fetchPages") 48 | return pages 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/OnBoarding/MainBoarding/Presenter/OnBoardingPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnBoardingPresenter.swift 3 | // SniffMeet 4 | // 5 | // Created by 배현진 on 12/4/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol OnBoardingPresentable: AnyObject { 11 | var view: (any OnBoardingViewable)? { get set } 12 | var interactor: (any OnBoardingInteractable)? { get set } 13 | var router: (any OnBoardingRoutable)? { get set } 14 | 15 | func viewDidLoad() 16 | func skipOnboarding() 17 | func pageAt(index: Int) -> OnBoardingPage? 18 | } 19 | 20 | final class OnBoardingPresenter: OnBoardingPresentable { 21 | weak var view: (any OnBoardingViewable)? 22 | var interactor: (any OnBoardingInteractable)? 23 | var router: (any OnBoardingRoutable)? 24 | private var pages: [OnBoardingPage] = [] 25 | 26 | init( 27 | view: (any OnBoardingViewable)? = nil, 28 | interactor: (any OnBoardingInteractable)? = nil, 29 | router: (any OnBoardingRoutable)? = nil 30 | ) { 31 | self.view = view 32 | self.interactor = interactor 33 | self.router = router 34 | } 35 | 36 | func viewDidLoad() { 37 | SNMLogger.log("presenter viewDidLoad") 38 | pages = interactor?.fetchPages() ?? [] 39 | SNMLogger.info("page3: \(pages)") 40 | view?.updatePages(pages) 41 | } 42 | 43 | func skipOnboarding() { 44 | router?.navigateToMainScreen() 45 | } 46 | 47 | func pageAt(index: Int) -> OnBoardingPage? { 48 | guard index >= 0 && index < pages.count else { return nil } 49 | return pages[index] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/OnBoarding/MainBoarding/Router/OnBoardingRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnBoardingRouter.swift 3 | // SniffMeet 4 | // 5 | // Created by 배현진 on 12/4/24. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol OnBoardingRoutable { 11 | func navigateToMainScreen() 12 | } 13 | 14 | protocol OnBoardingBuilable { 15 | static func createModule() -> UIViewController 16 | } 17 | 18 | final class OnBoardingRouter: OnBoardingRoutable { 19 | 20 | func navigateToMainScreen() { 21 | Task { @MainActor in 22 | if let sceneDelegate = UIApplication.shared.connectedScenes 23 | .first(where: { $0.activationState == .foregroundActive })? 24 | .delegate as? SceneDelegate { 25 | if let router = sceneDelegate.appRouter { 26 | router.displayProfileSetupView() 27 | } 28 | } 29 | } 30 | } 31 | } 32 | 33 | extension OnBoardingRouter: OnBoardingBuilable { 34 | static func createModule() -> UIViewController { 35 | let view = OnBoardingViewController() 36 | let interactor = OnBoardingInteractor() 37 | let router: OnBoardingRoutable & OnBoardingBuilable = OnBoardingRouter() 38 | let presenter: OnBoardingPresentable = OnBoardingPresenter() 39 | 40 | view.presenter = presenter 41 | presenter.view = view 42 | presenter.interactor = interactor 43 | presenter.router = router 44 | interactor.presenter = presenter 45 | 46 | return view 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Persistence/UserDefaultsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaultsManager.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/7/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol UserDefaultsManagable { 11 | func get(forKey: String, type: T.Type) throws -> T where T: Decodable 12 | func set(value: T, forKey: String) throws where T: Encodable 13 | func delete(forKey: String) throws 14 | } 15 | 16 | /// UserDefaults를 관리하는 클래스입니다. 17 | /// 생성 시 userDefaults 파라미터에 아무것도 주입하지 않으면 standard를 기본으로 사용합니다. 18 | final class UserDefaultsManager: UserDefaultsManagable { 19 | private let userDefaults: UserDefaults 20 | private let jsonEncoder: JSONEncoder 21 | private let jsonDecoder: JSONDecoder 22 | 23 | init( 24 | userDefaults: UserDefaults = .standard, 25 | jsonEncoder: JSONEncoder, 26 | jsonDecoder: JSONDecoder 27 | ) { 28 | self.userDefaults = userDefaults 29 | self.jsonEncoder = jsonEncoder 30 | self.jsonDecoder = jsonDecoder 31 | } 32 | 33 | /// 값이 UserDefaults에 없는 경우 nil을 반환합니다. 34 | func get(forKey key: String, type: T.Type) throws -> T where T: Decodable { 35 | guard let data = userDefaults.data(forKey: key) 36 | else { 37 | throw UserDefaultsError.notFound 38 | } 39 | 40 | do { 41 | let decodedValue = try jsonDecoder.decode(type, from: data) 42 | return decodedValue 43 | } catch { 44 | throw UserDefaultsError.decodingError 45 | } 46 | } 47 | func set(value: T, forKey: String) throws where T: Encodable { 48 | do { 49 | let encodedData = try jsonEncoder.encode(value) 50 | userDefaults.setValue(encodedData, forKey: forKey) 51 | } catch { 52 | throw UserDefaultsError.encodingError 53 | } 54 | } 55 | func delete(forKey key: String) throws { 56 | if userDefaults.data(forKey: key) != nil { 57 | userDefaults.removeObject(forKey: key) 58 | } else { 59 | throw UserDefaultsError.noDeleteObject 60 | } 61 | } 62 | } 63 | 64 | // MARK: - UserDefaultsManager+Singleton instance 65 | 66 | extension UserDefaultsManager { 67 | /// UserDefaultsManager 싱글톤 인스턴스입니다. 내부적으로 UserDefaults.standard를 사용합니다. 68 | static let shared: UserDefaultsManager = UserDefaultsManager( 69 | userDefaults: .standard, 70 | jsonEncoder: JSONEncoder(), 71 | jsonDecoder: JSONDecoder() 72 | ) 73 | } 74 | 75 | // MARK: - UserDefaultsError 76 | 77 | enum UserDefaultsError: LocalizedError { 78 | case notFound 79 | case encodingError 80 | case decodingError 81 | case noDeleteObject 82 | 83 | var errorDescription: String? { 84 | switch self { 85 | case .notFound: "key에 대한 값을 찾을 수 없습니다." 86 | case .encodingError: "인코딩 에러" 87 | case .decodingError: "디코딩 에러" 88 | case .noDeleteObject: "삭제할 대상을 찾을 수 없습니다." 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/PushNotification/PushNotificationConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PushNotificationConfig.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 12/2/24. 6 | // 7 | import Foundation 8 | 9 | enum PushNotificationConfig { 10 | static let baseURL = URL(string: "https://sniff.fly.dev")! 11 | } 12 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/PushNotification/PushNotificationRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Untitled.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 12/2/24. 6 | // 7 | import Foundation 8 | 9 | enum PushNotificationRequest { 10 | case sendWalkRequest(data: Data) 11 | case sendWalkRespond(data: Data) 12 | } 13 | 14 | extension PushNotificationRequest: SNMRequestConvertible { 15 | var endpoint: Endpoint { 16 | switch self { 17 | case .sendWalkRequest: 18 | return Endpoint( 19 | baseURL: PushNotificationConfig.baseURL, 20 | path: "notification/walkRequest", 21 | method: .post 22 | ) 23 | case .sendWalkRespond: 24 | return Endpoint(baseURL: PushNotificationConfig.baseURL, 25 | path: "notification/walkRespond", 26 | method: .post) 27 | } 28 | } 29 | 30 | var requestType: SNMRequestType { 31 | var header: [String: String] = [:] 32 | header["Content-Type"] = "application/json" 33 | switch self { 34 | case .sendWalkRequest(let data): 35 | return SNMRequestType.compositePlain( 36 | header: header, 37 | body: data 38 | ) 39 | case .sendWalkRespond(let data): 40 | return SNMRequestType.compositePlain(header: header, 41 | body: data) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Repository/Auth/DataManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataManager.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/14/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol DataStorable { 11 | func storeData(data: Encodable, key: String) throws 12 | } 13 | 14 | protocol DataLoadable { 15 | func loadData(forKey: String, type: T.Type) throws -> T where T: Decodable 16 | } 17 | 18 | // TODO: - dataManager를 주입받을 수 있도록 수정 예정 19 | final class LocalDataManager: DataStorable { 20 | private let dataManager = UserDefaultsManager(userDefaults: UserDefaults(suiteName: "demo")!, 21 | jsonEncoder: JSONEncoder(), 22 | jsonDecoder: JSONDecoder()) 23 | func storeData(data: any Encodable, key: String) throws { 24 | try dataManager.set(value: data, forKey: key) 25 | } 26 | } 27 | 28 | // MARK: - LocalDataManager+DataLoadable 29 | 30 | extension LocalDataManager: DataLoadable { 31 | func loadData(forKey: String, type: T.Type) throws -> T where T: Decodable { 32 | try dataManager.get(forKey: forKey, type: type) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/SNMNetwork/Core/Endpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EndPoint.swift 3 | // HGDNetwork 4 | // 5 | // Created by 윤지성 on 11/7/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Endpoint { 11 | public var baseURL: URL 12 | public var path: String 13 | public var method: HTTPMethod 14 | public var query: [String: String]? 15 | /// path와 query를 조합한 URL입니다. 조합에 실패시 baseURL을 반환합니다. 16 | public var absoluteURL: URL { 17 | let absoluteURLString: String = baseURL.absoluteString.trimmingCharacters( 18 | in: CharacterSet(charactersIn: "/") 19 | ) 20 | var components = URLComponents(string: absoluteURLString) 21 | components?.queryItems = query?.compactMap { 22 | URLQueryItem(name: $0, value: $1) 23 | } 24 | let previousPath = components?.path ?? "" 25 | components?.path = previousPath + "/\(path)" 26 | return components?.url ?? baseURL 27 | } 28 | } 29 | 30 | public enum HTTPMethod: String { 31 | case get = "GET" 32 | case post = "POST" 33 | case delete = "DELETE" 34 | case put = "PUT" 35 | case patch = "PATCH" 36 | } 37 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/SNMNetwork/Core/HTTPStatusCode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPStatusCode.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/16/24. 6 | // 7 | 8 | public enum HTTPStatusCode: Int { 9 | case okCode = 200 10 | case created = 201 11 | case accepted = 202 12 | case noContent = 204 13 | case resetContent = 205 14 | case partialContent = 206 15 | case multipleChoices = 300 16 | case movedPermanently = 301 17 | case found = 302 18 | case seeOther = 303 19 | case notModified = 304 20 | case temporaryRedirect = 307 21 | case badRequest = 400 22 | case unauthorization = 401 23 | case forbidden = 403 24 | case notFound = 404 25 | case methodNotAllowed = 405 26 | case notAcceptable = 406 27 | case proxyAuthenticationRequired = 407 28 | case requestTimeout = 408 29 | case conflict = 409 30 | case gone = 410 31 | case lengthRequired = 411 32 | case preconditionFailed = 412 33 | case contentTooLarge = 413 34 | case urlTooLong = 414 35 | case unsupportMediaType = 415 36 | case rangeNotSatisfiable = 416 37 | case expectationFailed = 417 38 | case misdirectedRequest = 421 39 | case tooManyRequests = 429 40 | case requestHeaderFieldsTooLarge = 431 41 | case internalServerError = 500 42 | case notImplemented = 501 43 | case badGateway = 502 44 | case serviceUnavailable = 503 45 | case gatewayTimeout = 504 46 | case httpVersionNotSupported = 505 47 | case insufficientStorage = 507 48 | case notExtended = 510 49 | 50 | /// 성공코드인지 확인합니다. 51 | var isSuccess: Bool { 52 | 200...304 ~= self.rawValue 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/SNMNetwork/Core/MultipartFormData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultipartFormData.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/17/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct MultipartFormData { 11 | private let crlf: String = "\r\n" 12 | let boundary: String 13 | private let parameters: [String: String] 14 | private let fileName: String 15 | private let mimeType: String 16 | private let contentData: Data 17 | 18 | public init( 19 | boundary: String = BoundaryGenerator.generate(), 20 | parameters: [String: String], 21 | fileName: String, 22 | mimeType: String, 23 | contentData: Data 24 | ) { 25 | self.boundary = boundary 26 | self.parameters = parameters 27 | self.fileName = fileName 28 | self.mimeType = mimeType 29 | self.contentData = contentData 30 | } 31 | 32 | var compositeBody: Data { 33 | var data: Data = Data() 34 | 35 | parameters.forEach { key, value in 36 | data.append("--\(boundary)\(crlf)") 37 | data.append(#"Content-Disposition: form-data; name="\#(key)""#) 38 | data.append("\(crlf)\(crlf)\(value)\(crlf)") 39 | } 40 | 41 | data.append("--\(boundary)\(crlf)") 42 | data.append(#"Content-Disposition: form-data; name="file"; filename="\#(fileName)""#) 43 | data.append("\(crlf)Content-Type: \(mimeType)\(crlf)\(crlf)") 44 | data.append(contentData) 45 | 46 | data.append("\(crlf)--\(boundary)--\(crlf)") 47 | 48 | return data 49 | } 50 | } 51 | 52 | public enum BoundaryGenerator { 53 | public static func generate() -> String { 54 | UUID().uuidString 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/SNMNetwork/Core/NetworkProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Provider.swift 3 | // HGDNetwork 4 | // 5 | // Created by 윤지성 on 11/7/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol NetworkProvider { 11 | func request(with: any SNMRequestConvertible) async throws -> SNMNetworkResponse 12 | } 13 | 14 | public final class SNMNetworkProvider: NetworkProvider { 15 | private let session: URLSession 16 | 17 | public init(session: URLSession = .shared) { 18 | self.session = session 19 | } 20 | 21 | public func request( 22 | with request: any SNMRequestConvertible 23 | ) async throws -> SNMNetworkResponse { 24 | do { 25 | let (data, response) = try await session.data(for: request.urlRequest()) 26 | guard let httpResponse = response as? HTTPURLResponse, 27 | let status = HTTPStatusCode(rawValue: httpResponse.statusCode) 28 | else { 29 | throw SNMNetworkError.invalidResponse(response: response) 30 | } 31 | guard status.isSuccess 32 | else { 33 | throw SNMNetworkError.failedStatusCode(reason: status) 34 | } 35 | let snmResponse = SNMNetworkResponse( 36 | statusCode: status, 37 | data: data, 38 | header: httpResponse.allHeaderFields 39 | ) 40 | return snmResponse 41 | } catch let error as URLError { 42 | throw SNMNetworkError.urlError(urlError: error) 43 | } 44 | catch let error as SNMNetworkError { 45 | throw error 46 | } catch { 47 | throw SNMNetworkError.unknown(error: error) 48 | } 49 | } 50 | } 51 | 52 | public enum SNMNetworkError: LocalizedError { 53 | case encodingError(with: any Encodable) 54 | case invalidRequest(request: any SNMRequestConvertible) 55 | case invalidURL(url: URL) 56 | case urlError(urlError: URLError) 57 | /// HTTPResponse로 변환 실패 58 | case invalidResponse(response: URLResponse) 59 | /// 실패로 분류되는 StatusCode입니다. 60 | case failedStatusCode(reason: HTTPStatusCode) 61 | case unknown(error: any Error) 62 | 63 | public var errorDescription: String? { 64 | switch self { 65 | case .encodingError(let data): 66 | "인코딩 에러입니다. \(data)" 67 | case .invalidRequest(let request): 68 | "잘못된 요청입니다. \(request)" 69 | case .invalidURL(let url): 70 | "URL이 잘못되었습니다. \(url)" 71 | case .urlError(let urlError): 72 | "URLError가 발생했습니다. \(urlError.localizedDescription)" 73 | case .invalidResponse(let response): 74 | "HTTP 응답이 아닙니다. \(response)" 75 | case .failedStatusCode(let reason): 76 | "실패 코드를 반환했습니다. statusCode: \(reason.rawValue)" 77 | case .unknown(let error): 78 | "알 수없는 에러입니다. \(error.localizedDescription)" 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/SNMNetwork/Core/SNMNetworkResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Response.swift 3 | // HGDNetwork 4 | // 5 | // Created by 윤지성 on 11/7/24. 6 | // 7 | import Foundation 8 | 9 | public final class SNMNetworkResponse { 10 | public let statusCode: HTTPStatusCode 11 | public let data: Data 12 | public let response: HTTPURLResponse? 13 | public let header: [AnyHashable : Any]? 14 | 15 | public init( 16 | statusCode: HTTPStatusCode, 17 | data: Data, 18 | response: HTTPURLResponse? = nil, 19 | header: [AnyHashable : Any]? 20 | ) { 21 | self.statusCode = statusCode 22 | self.data = data 23 | self.response = response 24 | self.header = header 25 | } 26 | } 27 | 28 | extension SNMNetworkResponse: CustomDebugStringConvertible, Equatable { 29 | public var description: String { 30 | "Status Code: \(statusCode), Data Length: \(data.count)" 31 | } 32 | 33 | public var debugDescription: String { description } 34 | 35 | public static func == (lhs: SNMNetworkResponse, rhs: SNMNetworkResponse) -> Bool { 36 | lhs.statusCode == rhs.statusCode 37 | && lhs.data == rhs.data 38 | && lhs.response == rhs.response 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/SNMNetwork/Core/SNMRequestConvertible.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestConvertible.swift 3 | // HGDNetwork 4 | // 5 | // Created by 윤지성 on 11/7/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol SNMRequestConvertible { 11 | var endpoint: Endpoint { get } 12 | var requestType: SNMRequestType { get } 13 | } 14 | 15 | public extension SNMRequestConvertible { 16 | /// Request가 유효한지 판단합니다. 17 | var isValid: Bool { 18 | switch requestType { 19 | // method가 get이고 body가 있는 requestType을 정의하면 invalid로 판단합니다. 20 | case .compositeJSONEncodable where endpoint.method == .get, 21 | .compositePlain where endpoint.method == .get, 22 | .jsonEncodableBody where endpoint.method == .get, 23 | .multipartFormData where endpoint.method == .get: 24 | false 25 | default: 26 | true 27 | } 28 | } 29 | 30 | func urlRequest() throws -> URLRequest { 31 | var urlRequest = URLRequest(url: endpoint.absoluteURL) 32 | urlRequest.httpMethod = endpoint.method.rawValue 33 | guard isValid 34 | else { 35 | throw SNMNetworkError.invalidRequest(request: self) 36 | } 37 | 38 | switch requestType { 39 | case .plain: 40 | break 41 | 42 | case .header(let header): 43 | urlRequest.append(header: header) 44 | 45 | case .jsonEncodableBody(let body): 46 | urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") 47 | try urlRequest.append(body: body) 48 | 49 | case .compositePlain(let header, let body): 50 | urlRequest.append(header: header) 51 | urlRequest.httpBody = body 52 | 53 | case .compositeJSONEncodable(let header, let body): 54 | var header = header 55 | header["Content-Type"] = "application/json" 56 | urlRequest.append(header: header) 57 | try urlRequest.append(body: body) 58 | 59 | case .multipartFormData(let header, let multipartFormData): 60 | var header = header 61 | header["Content-Type"] = "multipart/form-data; boundary=\(multipartFormData.boundary)" 62 | urlRequest.append(header: header) 63 | urlRequest.httpBody = multipartFormData.compositeBody 64 | } 65 | 66 | return urlRequest 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/SNMNetwork/Core/SNMRequestType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SNMRequestType.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/16/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum SNMRequestType { 11 | case plain 12 | case header(with: [String: String]) 13 | /// Content-Type: application/json이 자동으로 적용됩니다. 14 | case jsonEncodableBody(with: any Encodable) 15 | case compositePlain(header: [String: String], body: Data) 16 | case compositeJSONEncodable(header: [String: String], body: any Encodable) 17 | case multipartFormData(header: [String: String], multipartFormData: MultipartFormData) 18 | } 19 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/SNMNetwork/Helper/AnyEncodable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyEncodable.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/14/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct AnyEncodable { 11 | private let jsonEncoder: JSONEncoder 12 | private let value: any Encodable 13 | 14 | init(_ value: any Encodable, jsonEncoder: JSONEncoder = AnyEncodable.defaultJSONEncoder) { 15 | self.value = value 16 | self.jsonEncoder = jsonEncoder 17 | } 18 | 19 | func encode() throws -> Data { 20 | try jsonEncoder.encode(value) 21 | } 22 | } 23 | 24 | extension AnyEncodable { 25 | static let defaultJSONEncoder: JSONEncoder = JSONEncoder() 26 | } 27 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/SNMNetwork/Helper/Data + append.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Data + append.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/17/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Data { 11 | mutating func append(_ string: String) { 12 | self.append(Data(string.utf8)) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/SNMNetwork/Helper/URLRequest + append.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLRequest + append.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/16/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension URLRequest { 11 | mutating func append(header: [String: String]) { 12 | header.forEach { 13 | self.setValue($1, forHTTPHeaderField: $0) 14 | } 15 | } 16 | mutating func append(body: any Encodable) throws { 17 | do { 18 | let anyEncodableBody = AnyEncodable(body) 19 | let encodedData = try anyEncodableBody.encode() 20 | self.httpBody = encodedData 21 | } catch { 22 | throw SNMNetworkError.encodingError(with: body) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/SNMNetwork/Mock/MockRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockURLRequest.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/16/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum MockRequest: SNMRequestConvertible { 11 | case successFetch 12 | case successPost 13 | case invaildURL 14 | case getMethodWithBody 15 | 16 | var endpoint: Endpoint { 17 | let baseURL: URL = URL(string: "https://example.com")! 18 | switch self { 19 | case .successFetch: 20 | return Endpoint(baseURL: baseURL, path: "", method: .get) 21 | case .successPost: 22 | return Endpoint(baseURL: baseURL, path: "", method: .post) 23 | case .invaildURL: 24 | return Endpoint(baseURL: baseURL, path: "|||", method: .get) 25 | case .getMethodWithBody: 26 | return Endpoint(baseURL: baseURL, path: "", method: .get) 27 | } 28 | } 29 | 30 | var requestType: SNMRequestType { 31 | switch self { 32 | case .successFetch: .plain 33 | case .successPost: .compositeJSONEncodable(header: [:], body: Data()) 34 | case .invaildURL: .plain 35 | case .getMethodWithBody: .compositePlain(header: [:], body: Data()) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/SNMNetwork/Mock/MockURLProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockURLProtocol.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/16/24. 6 | // 7 | 8 | import Foundation 9 | 10 | final class MockURLProtocol: URLProtocol { 11 | override func startLoading() { 12 | guard let handler = MockURLProtocol.requestHandler else { 13 | client?.urlProtocol( 14 | self, 15 | didFailWithError: NSError(domain: "응답 핸들러를 정의하지 않았습니다.", code: -1) 16 | ) 17 | return 18 | } 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 | // TODO: 네트워크 작업 취소 테스트 필요 30 | override func stopLoading() {} 31 | } 32 | 33 | extension MockURLProtocol { 34 | /// 테스트에서 사용할 응답을 정의합니다. 35 | static var requestHandler: ((URLRequest) throws -> (URLResponse, Data))? 36 | 37 | override static func canInit(with request: URLRequest) -> Bool { 38 | true 39 | } 40 | override static func canonicalRequest(for request: URLRequest) -> URLRequest { 41 | request 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Supabase/Auth/Entity/Request/SupabaseTokenRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthBody.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/18/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct SupabaseTokenRequest: Encodable { 11 | var refreshToken: String 12 | 13 | enum CodingKeys: String, CodingKey { 14 | case refreshToken = "refresh_token" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Supabase/Auth/Entity/Response/SupabaseRefreshResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SupabaseRefreshResponse.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/19/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct SupabaseRefreshTokenResponse: Codable { 11 | var accessToken: String 12 | var refreshToken: String 13 | var expiresAt: Int 14 | var tokenType: String 15 | 16 | enum CodingKeys: String, CodingKey { 17 | case accessToken = "access_token" 18 | case refreshToken = "refresh_token" 19 | case expiresAt = "expires_At" 20 | case tokenType = "token_type" 21 | } 22 | } 23 | 24 | struct SupabaseRefreshUserResponse: Codable { 25 | var id: String 26 | 27 | enum CodingKeys: String, CodingKey { 28 | case id 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Supabase/Auth/Entity/Response/SupabaseSessionResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SupabaseSession.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/17/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct SupabaseSessionResponse: Decodable { 11 | var accessToken: String 12 | var tokenType: String 13 | var expiresIn: Int 14 | var expiresAt: Int 15 | var refreshToken: String 16 | var user: SupabaseUserResponse 17 | 18 | enum CodingKeys: String, CodingKey { 19 | case accessToken = "access_token" 20 | case tokenType = "token_type" 21 | case expiresIn = "expires_in" 22 | case expiresAt = "expires_at" 23 | case refreshToken = "refresh_token" 24 | case user 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Supabase/Auth/Entity/Response/SupabaseUserResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SupabaseUserResponse.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/19/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct SupabaseUserResponse: Decodable { 11 | var id: UUID 12 | } 13 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Supabase/Auth/Entity/SupabaseSession.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SupabaseSession.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/19/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct SupabaseSession { 11 | var accessToken: String 12 | var expiresAt: Int 13 | var refreshToken: String 14 | var user: SupabaseUser? 15 | } 16 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Supabase/Auth/Entity/SupabaseUser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SupabaseUser.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/19/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct SupabaseUser: Encodable { 11 | var userID: UUID 12 | 13 | init(from response: SupabaseUserResponse) { 14 | self.userID = response.id 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Supabase/Auth/SessionManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SessionManager.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/19/24. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | 11 | final class SessionManager { 12 | static let shared = SessionManager() 13 | var session: SupabaseSession? 14 | var isExpired: Bool { 15 | guard let session else { return true } 16 | // 세션 만료를 파악할 때는 30초의 여유시간을 줍니다. 17 | return Date(timeIntervalSince1970: TimeInterval(session.expiresAt + 30)) < Date() 18 | } 19 | 20 | private init() {} 21 | } 22 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Supabase/Auth/SupabaseRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SupabaseRequest.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/18/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum SupabaseRequest { 11 | case signInAnonymously 12 | case refreshToken(refreshToken: String) 13 | case refreshUser(accessToken: String) 14 | } 15 | 16 | extension SupabaseRequest: SNMRequestConvertible { 17 | var endpoint: Endpoint { 18 | switch self { 19 | case .signInAnonymously: 20 | return Endpoint( 21 | baseURL: SupabaseConfig.baseURL, 22 | path: "auth/v1/signup", 23 | method: .post 24 | ) 25 | case .refreshToken: 26 | return Endpoint( 27 | baseURL: SupabaseConfig.baseURL, 28 | path: "auth/v1/token", 29 | method: .post, 30 | query: [ 31 | "grant_type": "refresh_token" 32 | ] 33 | ) 34 | case .refreshUser: 35 | return Endpoint( 36 | baseURL: SupabaseConfig.baseURL, 37 | path: "auth/v1/user", 38 | method: .get 39 | ) 40 | } 41 | 42 | } 43 | var requestType: SNMRequestType { 44 | var header = [ 45 | "Content-Type": "application/json", 46 | "Authorization": "Bearer \(SupabaseConfig.apiKey)", 47 | "apikey": SupabaseConfig.apiKey 48 | ] 49 | switch self { 50 | case .signInAnonymously: 51 | return SNMRequestType.compositePlain( 52 | header: header, 53 | body: Data("{}".utf8) 54 | ) 55 | case .refreshToken(let refreshToken): 56 | header["Authorization"] = nil 57 | return SNMRequestType.compositePlain( 58 | header: header, 59 | body: Data("{ \"refresh_token\": \"\(refreshToken)\" }".utf8) 60 | ) 61 | case .refreshUser(let accessToken): 62 | header["Authorization"] = "Bearer \(accessToken)" 63 | return SNMRequestType.header( 64 | with: header 65 | ) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Supabase/Storage/SupabaseStorageManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SupabaseStorageManager.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/24/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct RemoteImage { 11 | let isModified: Bool 12 | let imageData: Data? 13 | let lastModified: String? 14 | } 15 | 16 | protocol RemoteImageManagable { 17 | func upload(imageData: Data, fileName: String, mimeType: MimeType) async throws 18 | // func download(fileName: String) async throws -> Data 19 | func download(fileName: String, lastModified: String) async throws -> RemoteImage 20 | } 21 | 22 | struct SupabaseStorageManager: RemoteImageManagable { 23 | private let networkProvider: SNMNetworkProvider 24 | 25 | init(networkProvider: SNMNetworkProvider) { 26 | self.networkProvider = networkProvider 27 | } 28 | func upload( 29 | imageData: Data, 30 | fileName: String, 31 | mimeType: MimeType = .image 32 | ) async throws { 33 | if SessionManager.shared.isExpired { 34 | try await SupabaseAuthManager.shared.refreshSession() 35 | } 36 | guard let session = SessionManager.shared.session else { 37 | throw SupabaseError.sessionNotExist 38 | } 39 | let response = try await networkProvider.request( 40 | with: SupabaseStorageRequest.upload( 41 | accessToken: session.accessToken, 42 | image: imageData, 43 | fileName: fileName, 44 | mimeType: mimeType 45 | ) 46 | ) 47 | } 48 | func download(fileName: String, lastModified: String) async throws -> RemoteImage { 49 | let response: SNMNetworkResponse = try await networkProvider.request( 50 | with: SupabaseStorageRequest.download(fileName: fileName, 51 | lastModified: lastModified) 52 | ) 53 | let recentLastModified = response.header?["Last-Modified"] as? String 54 | let imageResponse = RemoteImage(isModified: response.statusCode != .notModified, 55 | imageData: response.data, 56 | lastModified: recentLastModified ?? "" ) 57 | return imageResponse 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Supabase/Storage/SupabaseStorageRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SupabaseStorageRequest.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/24/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum SupabaseStorageRequest { 11 | case upload( 12 | accessToken: String, 13 | image: Data, 14 | fileName: String, 15 | mimeType: MimeType 16 | ) 17 | case download(fileName: String, lastModified: String) 18 | } 19 | 20 | // MARK: - SupabaseStorageRequest+SNMRequestConvertible 21 | 22 | extension SupabaseStorageRequest: SNMRequestConvertible { 23 | var endpoint: Endpoint { 24 | switch self { 25 | case .upload(_, _, let fileName, _): 26 | Endpoint( 27 | baseURL: SupabaseConfig.baseURL, 28 | path: "storage/v1/object/\(SupabaseConfig.bucketName)/\(fileName)", 29 | method: .post 30 | ) 31 | case .download(let fileName, _): 32 | Endpoint( 33 | baseURL: SupabaseConfig.baseURL, 34 | path: "storage/v1/object/\(SupabaseConfig.bucketName)/\(fileName)", 35 | method: .get 36 | ) 37 | } 38 | } 39 | 40 | var requestType: SNMRequestType { 41 | switch self { 42 | case .upload(let accessToken, let imageData, let fileName, let mimeType): 43 | .multipartFormData( 44 | header: [ 45 | "apiKey" : SupabaseConfig.apiKey, 46 | "Authorization" : "Bearer \(accessToken)" 47 | ], 48 | multipartFormData: MultipartFormData( 49 | parameters: [:], 50 | fileName: fileName, 51 | mimeType: mimeType.value, 52 | contentData: imageData 53 | ) 54 | ) 55 | case .download(_, let lastModified): 56 | .header(with: [ 57 | "apiKey" : SupabaseConfig.apiKey, 58 | "If-Modified-Since": lastModified 59 | ]) 60 | } 61 | } 62 | } 63 | 64 | enum MimeType { 65 | case plainText 66 | case imageJPEG 67 | case imagePNG 68 | case image 69 | 70 | var value: String { 71 | switch self { 72 | case .plainText: "text/plain" 73 | case .imageJPEG: "image/jpeg" 74 | case .imagePNG: "image/png" 75 | case .image: "image/*" 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Supabase/SupabaseConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SupabaseManager.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/17/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum SupabaseConfig { 11 | static let baseURL = URL(string: "https://lltjsznuclppbhxfwslo.supabase.co")! 12 | static let apiKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxsdGpzem51Y2xwcGJoeGZ3c2xvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzE0MDA5MjgsImV4cCI6MjA0Njk3NjkyOH0.qrzmB2tiwWevRhKQeptsf5m8iCLUIkscuQ1MzKNtqh4" // 공개키 13 | static let bucketName: String = "image" 14 | } 15 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Supabase/SupabaseError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SupabaseAuthError.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/19/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum SupabaseError: Error { 11 | case sessionNotExist 12 | case notFound 13 | } 14 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Tab/TabBarModuleBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabBarModuleBuilder.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/11/24. 6 | // 7 | 8 | import UIKit 9 | 10 | enum TabBarModuleBuilder { 11 | static func build(usingSubmodules submodules: MainTabs) -> UITabBarController { 12 | let homeTabBarItem = UITabBarItem( 13 | title: "Home", 14 | image: UIImage(systemName: "house.fill"), 15 | tag: 0 16 | ) 17 | // let walkTabBarItem = UITabBarItem( 18 | // title: "walk", 19 | // image: UIImage(systemName: "dog.fill"), 20 | // tag: 1 21 | // ) 22 | let mateTabBarItem = UITabBarItem( 23 | title: "mate", 24 | image: UIImage(systemName: "heart.fill"), 25 | tag: 2 26 | ) 27 | 28 | submodules.home.tabBarItem = homeTabBarItem 29 | // submodules.walk.tabBarItem = walkTabBarItem 30 | submodules.mate.tabBarItem = mateTabBarItem 31 | 32 | // let tabs = (submodules.home, submodules.walk, submodules.mate) 33 | let tabs = (submodules.home, submodules.mate) 34 | let tabBarController = TabBarController(tabs: tabs) 35 | return tabBarController 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Tab/View/TabBarController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabBarController.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/11/24. 6 | // 7 | 8 | import UIKit 9 | 10 | typealias MainTabs = ( 11 | home: UIViewController, 12 | // walk: UIViewController, 13 | mate: UIViewController 14 | ) 15 | 16 | final class TabBarController: UITabBarController { 17 | init(tabs: MainTabs) { 18 | super.init(nibName: nil, bundle: nil) 19 | // viewControllers = [tabs.home, tabs.walk, tabs.mate] 20 | viewControllers = [tabs.home, tabs.mate] 21 | } 22 | 23 | @available(*, unavailable) 24 | required init?(coder: NSCoder) { 25 | fatalError("init(coder:) has not been implemented") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/ComputeUseCase/CalculateTimeLimitUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalculateTimeLimitUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/26/24. 6 | // 7 | import Foundation 8 | 9 | protocol CalculateTimeLimitUseCase { 10 | func execute(requestTime: Date) -> Int 11 | } 12 | 13 | struct CalculateTimeLimitUseCaseImpl: CalculateTimeLimitUseCase { 14 | func execute(requestTime: Date) -> Int { 15 | let secondsDifference = requestTime.secondsDifferenceFromNow() 16 | let minuteSeconds = 60 17 | 18 | return minuteSeconds - secondsDifference > 0 ? minuteSeconds - secondsDifference : 0 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/ComputeUseCase/ConvertLocationToTextUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConvertLocationToTextUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/19/24. 6 | // 7 | 8 | import CoreLocation 9 | 10 | protocol ConvertLocationToTextUseCase { 11 | func execute(latitude: Double, longtitude: Double) async -> String? 12 | } 13 | 14 | struct ConvertLocationToTextUseCaseImpl: ConvertLocationToTextUseCase { 15 | private let geoCoder: CLGeocoder = CLGeocoder() 16 | 17 | func execute(latitude: Double, longtitude: Double) async -> String? { 18 | let placemarks = try? await geoCoder.reverseGeocodeLocation( 19 | CLLocation(latitude: latitude, longitude: longtitude), 20 | preferredLocale: .current 21 | ) 22 | return placemarks?.first?.name 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/ComputeUseCase/ConvertToWalkAPSUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConvertToWalkAPSUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/28/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol ConvertToWalkAPSUseCase { 11 | func execute(walkAPSUserInfo: [AnyHashable: Any]) -> WalkAPSDTO? 12 | } 13 | 14 | struct ConvertToWalkAPSUseCaseImpl: ConvertToWalkAPSUseCase { 15 | private let jsonDecoder: JSONDecoder 16 | 17 | init(jsonDecoder: JSONDecoder = AnyDecodable.defaultDecoder) { 18 | self.jsonDecoder = jsonDecoder 19 | } 20 | 21 | func execute(walkAPSUserInfo: [AnyHashable : Any]) -> WalkAPSDTO? { 22 | try? AnyJSONSerializable( 23 | value: walkAPSUserInfo, 24 | jsonDecoder: jsonDecoder 25 | )?.decode(type: WalkAPSDTO.self) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/RequestUseCase/CheckFirstLaunchUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CheckFirstLaunchUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 12/1/24. 6 | // 7 | 8 | protocol CheckFirstLaunchUseCase { 9 | func execute() -> Bool 10 | } 11 | 12 | struct CheckFirstLaunchUseCaseImpl: CheckFirstLaunchUseCase { 13 | private let userDefaultsManager: any UserDefaultsManagable 14 | 15 | init(userDefaultsManager: any UserDefaultsManagable) { 16 | self.userDefaultsManager = userDefaultsManager 17 | } 18 | 19 | func execute() -> Bool { 20 | let isFirstLaunch = try? userDefaultsManager.get( 21 | forKey: Environment.UserDefaultsKey.isFirstLaunch, 22 | type: Bool.self 23 | ) 24 | return isFirstLaunch == nil 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/RequestUseCase/LoadUserInfoUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadInfoUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/18/24. 6 | // 7 | 8 | protocol LoadUserInfoUseCase { 9 | func execute() throws -> UserInfo 10 | } 11 | 12 | struct LoadUserInfoUseCaseImpl: LoadUserInfoUseCase { 13 | private let dataLoadable: (any DataLoadable) 14 | private let imageManageable: (any ImageManagable) 15 | 16 | init(dataLoadable: any DataLoadable, imageManageable: any ImageManagable) { 17 | self.dataLoadable = dataLoadable 18 | self.imageManageable = imageManageable 19 | } 20 | 21 | func execute() throws -> UserInfo { 22 | var userInfo = try dataLoadable.loadData(forKey: Environment.UserDefaultsKey.dogInfo, 23 | type: UserInfo.self) 24 | userInfo.profileImage = try imageManageable.image(forKey: Environment.FileManagerKey.profileImage) 25 | return userInfo 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/RequestUseCase/RequestLocationAuthUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestLocationAuthUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/19/24. 6 | // 7 | 8 | import CoreLocation 9 | 10 | protocol RequestLocationAuthUseCase { 11 | func execute() 12 | } 13 | 14 | struct RequestLocationAuthUseCaseImpl: RequestLocationAuthUseCase { 15 | private let locationManager: CLLocationManager 16 | 17 | init(locationManager: CLLocationManager) { 18 | self.locationManager = locationManager 19 | } 20 | 21 | func execute() { 22 | locationManager.requestWhenInUseAuthorization() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/RequestUseCase/RequestMateInfoUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestMateInfoUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/28/24. 6 | // 7 | 8 | import Foundation 9 | 10 | // RequestUserInfoUseCase와 통합이 가능하다고 예상됩니다. 11 | protocol RequestMateInfoUseCase { 12 | func execute(mateId: UUID) async throws -> UserInfoDTO? 13 | } 14 | 15 | struct RequestMateInfoUsecaseImpl: RequestMateInfoUseCase { 16 | func execute(mateId: UUID) async throws -> UserInfoDTO? { 17 | let mateInfoData = try await SupabaseDatabaseManager.shared.fetchData( 18 | from: "user_info", 19 | query: ["id": "eq.\(mateId.uuidString)"] 20 | ) 21 | let mateInfo = try JSONDecoder().decode([UserInfoDTO].self, from: mateInfoData) 22 | return mateInfo.first 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/RequestUseCase/RequestMateListUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestMateListUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/24/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol RequestMateListUseCase { 11 | var remoteDatabaseManager: RemoteDatabaseManager { get } 12 | func execute() async -> [Mate] 13 | } 14 | 15 | struct RequestMateListUseCaseImpl: RequestMateListUseCase { 16 | var remoteDatabaseManager: (any RemoteDatabaseManager) 17 | let decoder: JSONDecoder 18 | let encoder: JSONEncoder 19 | 20 | init(remoteDatabaseManager: any RemoteDatabaseManager) { 21 | self.remoteDatabaseManager = remoteDatabaseManager 22 | decoder = JSONDecoder() 23 | encoder = JSONEncoder() 24 | } 25 | 26 | func execute() async -> [Mate] { 27 | do { 28 | let tableName = Environment.SupabaseTableName.matelistFunction 29 | guard let userID = SessionManager.shared.session?.user?.userID else { return [] } 30 | let requestData = try encoder.encode(MateListRequestDTO(userId: userID)) 31 | 32 | let data = try await remoteDatabaseManager.fetchList(into: tableName,with: requestData) 33 | let mateDTOList = try decoder.decode([UserInfoDTO].self, from: data) 34 | return mateDTOList.map { 35 | Mate(name: $0.dogName, 36 | userID: $0.id, 37 | keywords: $0.keywords, 38 | profileImageURLString: $0.profileImageURL) 39 | } 40 | } catch { 41 | SNMLogger.error("RequestMateListUseCaseImpl: \(error.localizedDescription)") 42 | return [] 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/RequestUseCase/RequestNotiListUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestNotiListUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/30/24. 6 | // 7 | import Foundation 8 | 9 | protocol RequestNotiListUseCase { 10 | var remoteManager: (any RemoteDatabaseManager) { get } 11 | func execute() async -> [WalkNoti] 12 | } 13 | 14 | struct RequestNotiListUseCaseImpl: RequestNotiListUseCase { 15 | var remoteManager: (any RemoteDatabaseManager) 16 | let encoder: JSONEncoder 17 | let decoder: JSONDecoder 18 | 19 | init(remoteManager: any RemoteDatabaseManager) { 20 | self.remoteManager = remoteManager 21 | decoder = JSONDecoder() 22 | encoder = JSONEncoder() 23 | } 24 | 25 | func execute() async -> [WalkNoti] { 26 | let tableName = Environment.SupabaseTableName.notificationListFunction 27 | 28 | do { 29 | guard let userID = SessionManager.shared.session?.user?.userID else { return [] } 30 | 31 | let requestData = try encoder.encode(WalkNotiListRequestDTO(userId: userID)) 32 | let data = try await remoteManager.fetchList(into: tableName, with: requestData) 33 | let walkDTOList = try decoder.decode([WalkNotiDTO].self, from: data) 34 | 35 | return walkDTOList.map { $0.toEntity() } 36 | } catch { 37 | SNMLogger.error("RequestNotiListUseCaseImpl: \(error.localizedDescription)") 38 | return [] 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/RequestUseCase/RequestNotificationAuthUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestNotificationAuthUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/28/24. 6 | // 7 | 8 | import UserNotifications 9 | 10 | protocol RequestNotificationAuthUseCase { 11 | func execute() async throws -> Bool 12 | } 13 | 14 | struct RequestNotificationAuthUseCaseImpl: RequestNotificationAuthUseCase { 15 | private let userNotificationCenter: UNUserNotificationCenter 16 | 17 | init(userNotificationCenter: UNUserNotificationCenter = .current()) { 18 | self.userNotificationCenter = userNotificationCenter 19 | } 20 | 21 | func execute() async throws -> Bool { 22 | try await userNotificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/RequestUseCase/RequestProfileImageUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestProfileImageUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/24/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol RequestProfileImageUseCase { 11 | func execute(fileName: String) async throws -> Data? 12 | } 13 | 14 | struct RequestProfileImageUseCaseImpl: RequestProfileImageUseCase { 15 | private let remoteImageManager: any RemoteImageManagable 16 | private let cacheManager: any ImageCacheable 17 | 18 | init( 19 | remoteImageManager: any RemoteImageManagable, 20 | cacheManager: any ImageCacheable 21 | ) { 22 | self.remoteImageManager = remoteImageManager 23 | self.cacheManager = cacheManager 24 | } 25 | 26 | func execute(fileName: String) async throws -> Data? { 27 | if let cacheableImage = cacheManager.image(urlString: fileName) { // 캐시에 있을 때 28 | do { 29 | let remoteImage = try await remoteImageManager.download( 30 | fileName: fileName, 31 | lastModified: cacheableImage.lastModified 32 | ) 33 | if !remoteImage.isModified { // 변경되지 않음 34 | SNMLogger.log("not modified") 35 | return cacheableImage.imageData 36 | } else { 37 | cacheManager.save(urlString: fileName, 38 | lastModified: remoteImage.lastModified, 39 | imageData: remoteImage.imageData) 40 | return remoteImage.imageData! 41 | } 42 | } catch { 43 | SNMLogger.error("RequestProfileImageUseCaseImpl: \(error.localizedDescription)") 44 | return nil 45 | } 46 | } else { // 캐시에 없을 때 47 | do { 48 | let remoteImage = try await remoteImageManager.download( 49 | fileName: fileName, 50 | lastModified: "" 51 | ) 52 | cacheManager.save(urlString: fileName, 53 | lastModified: remoteImage.lastModified, 54 | imageData: remoteImage.imageData) 55 | return remoteImage.imageData 56 | } catch { 57 | SNMLogger.error("RequestProfileImageUseCaseImpl: \(error.localizedDescription)") 58 | return nil 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/RequestUseCase/RequestUserInfoRemoteUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestUserInfoRemoteUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by 배현진 on 11/27/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol RequestUserInfoRemoteUseCase { 11 | func execute() async throws -> [UserInfoDTO] 12 | } 13 | 14 | struct RequestUserInfoRemoteUseCaseImpl: RequestUserInfoRemoteUseCase { 15 | func execute() async throws -> [UserInfoDTO] { 16 | guard let userID = SessionManager.shared.session?.user?.userID else { 17 | throw SupabaseError.sessionNotExist 18 | } 19 | let data = try await SupabaseDatabaseManager.shared.fetchData( 20 | from: Environment.SupabaseTableName.userInfo, 21 | query: ["id": "eq.\(userID)"]) 22 | let decoder = JSONDecoder() 23 | let info = try decoder.decode([UserInfoDTO].self, from: data) 24 | return info 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/RequestUseCase/RequestUserLocationUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestUserLocationUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/19/24. 6 | // 7 | 8 | import CoreLocation 9 | 10 | protocol RequestUserLocationUseCase { 11 | func execute() -> CLLocation? 12 | } 13 | 14 | final class RequestUserLocationUseCaseImpl: RequestUserLocationUseCase { 15 | private let locationManager: CLLocationManager 16 | 17 | init(locationManager: CLLocationManager) { 18 | self.locationManager = locationManager 19 | } 20 | 21 | func execute() -> CLLocation? { 22 | locationManager.startUpdatingLocation() 23 | return locationManager.location 24 | } 25 | 26 | deinit { 27 | locationManager.stopUpdatingLocation() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/RequestUseCase/RequestWalkUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestWalk.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/18/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol RequestWalkUseCase { 11 | func execute(walkNoti: WalkNotiDTO) async throws 12 | } 13 | 14 | struct RequestWalkUseCaseImpl: RequestWalkUseCase { 15 | private let session: URLSession 16 | private let encoder = JSONEncoder() 17 | private let remoteDatabaseManager: RemoteDatabaseManager 18 | 19 | init(session: URLSession = URLSession.shared, remoteDatabaseManager: RemoteDatabaseManager) { 20 | self.session = session 21 | self.remoteDatabaseManager = remoteDatabaseManager 22 | } 23 | func execute(walkNoti: WalkNotiDTO) async throws { 24 | guard let requestData = try? encoder.encode(walkNoti) else { return } 25 | let request = try PushNotificationRequest.sendWalkRequest(data: requestData).urlRequest() 26 | let (_, response) = try await session.data(for: request) 27 | 28 | do { 29 | let requestData = WalkRequestInsertDTO(id: walkNoti.id, 30 | createdAt: walkNoti.createdAt, 31 | sender: walkNoti.senderId, 32 | receiver: walkNoti.receiverId, 33 | message: walkNoti.message, 34 | latitude: walkNoti.latitude, 35 | longitude: walkNoti.longtitude, 36 | state: .pending) 37 | let data = try encoder.encode(requestData) 38 | try await remoteDatabaseManager.insertData( 39 | into: Environment.SupabaseTableName.walkRequest, 40 | with: data 41 | ) 42 | } catch { 43 | SNMLogger.error("notifiaction list insert error: \(error.localizedDescription)") 44 | } 45 | 46 | if let response = response as? HTTPURLResponse { 47 | SNMLogger.log("RequestWalkUseCaseImpl: \(response)") 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/RespondUseCase/RespondMateRequestUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AcceptMateRequestUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/27/24. 6 | // 7 | import Foundation 8 | 9 | protocol RespondMateRequestUseCase { 10 | var localDataManager: DataStorable & DataLoadable { get } 11 | func execute(mateId: UUID, isAccepted: Bool) async 12 | } 13 | 14 | struct RespondMateRequestUseCaseImpl: RespondMateRequestUseCase { 15 | var localDataManager: DataStorable & DataLoadable 16 | var remoteDataManger: RemoteDatabaseManager 17 | 18 | func execute(mateId: UUID, isAccepted: Bool) async { 19 | if isAccepted { 20 | await addMate(mateId: mateId) 21 | } 22 | } 23 | func addMate(mateId: UUID) async { 24 | var mateList: [UUID] = [] 25 | let encoder = JSONEncoder() 26 | do { 27 | mateList = try localDataManager.loadData(forKey: Environment.UserDefaultsKey.mateList, type: [UUID].self) 28 | } catch { 29 | mateList = [] 30 | } 31 | do { 32 | guard let userID = SessionManager.shared.session?.user?.userID else { 33 | throw SupabaseError.sessionNotExist 34 | } 35 | 36 | mateList.append(mateId) 37 | mateList = Array(Set(mateList)) 38 | let mateListData = try encoder.encode(MateListDTO(mates: mateList)) 39 | try await remoteDataManger.updateData(into: Environment.SupabaseTableName.matelist, 40 | with: mateListData) 41 | 42 | try localDataManager.storeData(data:mateList, key: Environment.UserDefaultsKey.mateList) 43 | } catch { 44 | SNMLogger.error("AcceptMateRequestUsecaseError: \(error.localizedDescription)") 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/RespondUseCase/RespondWalkRequestUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RespondWalkRequestUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/20/24. 6 | // 7 | import Foundation 8 | 9 | protocol RespondWalkRequestUseCase { 10 | func execute(requestID: UUID, walkNoti: WalkNotiDTO) async throws 11 | } 12 | 13 | struct RespondWalkRequestUseCaseImpl: RespondWalkRequestUseCase { 14 | private let session: URLSession 15 | private let encoder = JSONEncoder() 16 | private let remoteDatabaseManager: RemoteDatabaseManager 17 | 18 | init(session: URLSession = URLSession.shared, remoteDatabaseManager: RemoteDatabaseManager) { 19 | self.session = session 20 | self.remoteDatabaseManager = remoteDatabaseManager 21 | } 22 | 23 | func execute(requestID: UUID, walkNoti: WalkNotiDTO) async throws { 24 | guard let requestData = try? encoder.encode(walkNoti) else { return } 25 | let request = try PushNotificationRequest.sendWalkRespond(data: requestData).urlRequest() 26 | let _ = try await session.data(for: request) 27 | 28 | // MARK: - walk-request 테이블 업데이트 29 | var tableData: WalkRequestUpdateDTO? 30 | switch walkNoti.category { 31 | case .walkAccepted: 32 | tableData = WalkRequestUpdateDTO(state: .accepted) 33 | case .walkDeclined: 34 | tableData = WalkRequestUpdateDTO(state: .declined) 35 | default: 36 | break 37 | } 38 | guard let tableData else { return } 39 | let data = try JSONEncoder().encode(tableData) 40 | Task { 41 | try await remoteDatabaseManager.updateData( 42 | into: Environment.SupabaseTableName.walkRequest, 43 | at: requestID, 44 | with: data) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/SaveUseCase/CreateAccountUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoreUserInfoRemoteUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by Kelly Chui on 11/26/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol CreateAccountUseCase { 11 | func execute(info: UserInfoDTO) async 12 | } 13 | 14 | struct CreateAccountUseCaseImpl: CreateAccountUseCase { 15 | // RLS 정책은 ID 기반으로 인증이 됩니다. 따라서 info에 id 정보가 필요합니다. 16 | func execute(info: UserInfoDTO) async { 17 | let encoder = JSONEncoder() 18 | do { 19 | let userData = try encoder.encode(info) 20 | try await SupabaseDatabaseManager.shared.insertData( 21 | into: Environment.SupabaseTableName.userInfo, 22 | with: userData 23 | ) 24 | 25 | } catch { 26 | SNMLogger.error("\(error.localizedDescription)") 27 | } 28 | do { 29 | let mateListData = try encoder.encode(MateListInsertDTO(id: info.id)) 30 | try await SupabaseDatabaseManager.shared.insertData( 31 | into: Environment.SupabaseTableName.matelist, 32 | with: mateListData 33 | ) 34 | } catch { 35 | SNMLogger.error("mate list insert error: \(error.localizedDescription)") 36 | } 37 | do { 38 | let notiListData = try encoder.encode(WalkNotiListInsertDTO(id: info.id)) 39 | try await SupabaseDatabaseManager.shared.insertData( 40 | into: Environment.SupabaseTableName.notificationList, 41 | with: notiListData 42 | ) 43 | } catch { 44 | SNMLogger.error("notifiaction list insert error: \(error.localizedDescription)") 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/SaveUseCase/RegisterDeviceTokenUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RegisterDeviceTokenUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/25/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol RegisterDeviceTokenUseCase { 11 | func execute(deviceToken: Data) throws 12 | } 13 | 14 | struct RegisterDeviceTokenUseCaseImpl: RegisterDeviceTokenUseCase { 15 | private let keychainManager: any KeychainManagable 16 | 17 | init(keychainManager: any KeychainManagable) { 18 | self.keychainManager = keychainManager 19 | } 20 | 21 | func execute(deviceToken: Data) throws { 22 | let deviceTokenString = deviceToken.reduce("") { $0 + String(format: "%02X", $1) } 23 | try keychainManager.set( 24 | value: deviceTokenString, 25 | forKey: Environment.KeychainKey.deviceToken 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/SaveUseCase/RemoteSaveDeviceTokenUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteSaveDeviceTokenUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/27/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol RemoteSaveDeviceTokenUseCase { 11 | func execute() async throws 12 | } 13 | 14 | struct RemoteSaveDeviceTokenUseCaseImpl: RemoteSaveDeviceTokenUseCase { 15 | private let jsonEncoder: JSONEncoder 16 | private let keychainManager: any KeychainManagable 17 | private let remoteDBManager: any RemoteDatabaseManager 18 | 19 | init( 20 | jsonEncoder: JSONEncoder, 21 | keychainManager: any KeychainManagable, 22 | remoteDBManager: any RemoteDatabaseManager 23 | ) { 24 | self.jsonEncoder = jsonEncoder 25 | self.keychainManager = keychainManager 26 | self.remoteDBManager = remoteDBManager 27 | } 28 | 29 | func execute() async throws { 30 | let deviceToken = try keychainManager.get(forKey: Environment.KeychainKey.deviceToken) 31 | let deviceTokenDTO = SaveDeviceTokenDTO(deviceToken: deviceToken) 32 | let deviceTokenData = try jsonEncoder.encode(deviceTokenDTO) 33 | try await remoteDBManager.updateData( 34 | into: Environment.SupabaseTableName.userInfo, 35 | with: deviceTokenData 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/SaveUseCase/SaveFirstLaunchUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SaveFirstLaunchUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 12/1/24. 6 | // 7 | 8 | protocol SaveFirstLaunchUseCase { 9 | func execute() throws 10 | } 11 | 12 | struct SaveFirstLaunchUseCaseImpl: SaveFirstLaunchUseCase { 13 | private let userDefaultsManager: any UserDefaultsManagable 14 | 15 | init(userDefaultsManager: any UserDefaultsManagable) { 16 | self.userDefaultsManager = userDefaultsManager 17 | } 18 | 19 | func execute() throws { 20 | try userDefaultsManager.set( 21 | value: true, 22 | forKey: Environment.UserDefaultsKey.isFirstLaunch 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/SaveUseCase/SaveProfileImageUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SaveProfileImageUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/25/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol SaveProfileImageUseCase { 11 | /// fileName을 반환합니다. 12 | func execute(imageData: Data) async throws -> String 13 | } 14 | 15 | struct SaveProfileImageUseCaseImpl: SaveProfileImageUseCase { 16 | private let remoteImageManager: any RemoteImageManagable 17 | private let userDefaultsManager: any UserDefaultsManagable 18 | 19 | init( 20 | remoteImageManager: any RemoteImageManagable, 21 | userDefaultsManager: any UserDefaultsManagable 22 | ) { 23 | self.remoteImageManager = remoteImageManager 24 | self.userDefaultsManager = userDefaultsManager 25 | } 26 | 27 | func execute(imageData: Data) async throws -> String { 28 | let fileName: String = UUID().uuidString 29 | try await remoteImageManager.upload( 30 | imageData: imageData, 31 | fileName: fileName, 32 | mimeType: .image 33 | ) 34 | try userDefaultsManager.set( 35 | value: fileName, 36 | forKey: Environment.UserDefaultsKey.profileImage 37 | ) 38 | return fileName 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/SaveUseCase/SaveUserInfoUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SaveInfoUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/14/24. 6 | // 7 | import Foundation 8 | 9 | protocol SaveUserInfoUseCase { 10 | func execute(dog: UserInfo) throws 11 | } 12 | 13 | struct SaveUserInfoUseCaseImpl: SaveUserInfoUseCase { 14 | let localDataManager: DataStorable 15 | let imageManager: ImageManagable 16 | 17 | func execute(dog: UserInfo) throws { 18 | try localDataManager.storeData(data: dog, key: Environment.UserDefaultsKey.dogInfo) 19 | guard let imageData = dog.profileImage else { return } 20 | do { 21 | try imageManager.setImage(imageData: imageData, 22 | forKey: Environment.FileManagerKey.profileImage) 23 | } catch { 24 | SNMLogger.error("프로필 이미지 저장 실패: \(error.localizedDescription)") 25 | } 26 | try localDataManager.storeData( 27 | data: [ 28 | UUID(uuidString: "f27c02f6-0110-4291-b866-a1ead0742755") ?? .init(), 29 | UUID(uuidString: "b79bc6b9-b776-4f5b-8f6c-48ba498b6e3a") ?? .init(), 30 | UUID(uuidString: "bda7ec28-1407-4871-93ea-c7835986726a") ?? .init(), 31 | UUID(uuidString: "a96ee934-03b9-43f3-b29b-53c3ba945363") ?? .init() 32 | ] , key: Environment.UserDefaultsKey.mateList) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/UseCase/SaveUseCase/UpdateUserInfoUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateUserInfoUseCase.swift 3 | // SniffMeet 4 | // 5 | // Created by 배현진 on 12/1/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol UpdateUserInfoUseCase { 11 | func execute(info: UserInfoDTO) async 12 | } 13 | 14 | struct UpdateUserInfoUseCaseImpl: UpdateUserInfoUseCase { 15 | func execute(info: UserInfoDTO) async { 16 | do { 17 | let userData = try JSONEncoder().encode(info) 18 | try await SupabaseDatabaseManager.shared.updateData( 19 | into: Environment.SupabaseTableName.userInfo, 20 | with: userData 21 | ) 22 | } catch { 23 | SNMLogger.error("\(error.localizedDescription)") 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Walk/RequestWalk/Interactor/SelectLocationInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SelectLocationInteractor.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/19/24. 6 | // 7 | 8 | protocol SelectLocationInteractable: AnyObject { 9 | var presenter: SelectLocationInteractorOutput? { get set } 10 | 11 | func requestUserLocationAuth() 12 | func convertLocationToText(latitude: Double, longtitude: Double) 13 | } 14 | 15 | final class SelectLocationInteractor: SelectLocationInteractable { 16 | weak var presenter: (any SelectLocationInteractorOutput)? 17 | private let convertLocationToTextUseCase: any ConvertLocationToTextUseCase 18 | private let requestLocationAuthUseCase: any RequestLocationAuthUseCase 19 | 20 | init( 21 | presenter: (any SelectLocationInteractorOutput)? = nil, 22 | convertLocationToTextUseCase: any ConvertLocationToTextUseCase, 23 | requestLocationAuthUseCase: any RequestLocationAuthUseCase 24 | ) { 25 | self.presenter = presenter 26 | self.convertLocationToTextUseCase = convertLocationToTextUseCase 27 | self.requestLocationAuthUseCase = requestLocationAuthUseCase 28 | } 29 | 30 | func requestUserLocationAuth() { 31 | requestLocationAuthUseCase.execute() 32 | } 33 | func convertLocationToText(latitude: Double, longtitude: Double) { 34 | Task { 35 | let locationText: String? = await convertLocationToTextUseCase.execute( 36 | latitude: latitude, longtitude: longtitude 37 | ) 38 | presenter?.didConvertLocationToText(with: locationText) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Walk/RequestWalk/Router/SelectLocationModuleBuildable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SelectLocationModuleBuildable.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/19/24. 6 | // 7 | 8 | import CoreLocation 9 | import UIKit 10 | 11 | protocol SelectLocationModuleBuildable { 12 | static func build() -> UIViewController 13 | } 14 | 15 | extension SelectLocationModuleBuildable { 16 | static func build() -> UIViewController { 17 | let locationManager: CLLocationManager = CLLocationManager() 18 | let view: UIViewController & SelectLocationViewable = SelectLocationViewController() 19 | let interactor: SelectLocationInteractable = SelectLocationInteractor( 20 | convertLocationToTextUseCase: ConvertLocationToTextUseCaseImpl(), 21 | requestLocationAuthUseCase: RequestLocationAuthUseCaseImpl( 22 | locationManager: locationManager 23 | ) 24 | ) 25 | let presenter = SelectLocationPresenter() 26 | let router: SelectLocationRoutable = SelectLocationRouter() 27 | 28 | view.presenter = presenter 29 | presenter.view = view 30 | presenter.interactor = interactor 31 | presenter.router = router 32 | interactor.presenter = presenter 33 | router.presenter = presenter 34 | 35 | return view 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Walk/RequestWalk/Router/SelectLocationRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SelectLocationRouter.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/19/24. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol SelectLocationRoutable: AnyObject, Routable { 11 | var presenter: (any SelectLocationPresentable)? { get set } 12 | var delegate: (any SelectLocationRouterDelegate)? { get set } 13 | 14 | func dismiss(from: any SelectLocationViewable, with: Address?) 15 | func showAlert(from: any SelectLocationViewable, title: String, message: String) 16 | } 17 | 18 | protocol SelectLocationRouterDelegate: AnyObject { 19 | func didDismiss(router: any SelectLocationRoutable, address: Address?) 20 | } 21 | 22 | final class SelectLocationRouter: SelectLocationRoutable { 23 | weak var presenter: (any SelectLocationPresentable)? 24 | weak var delegate: (any SelectLocationRouterDelegate)? 25 | 26 | func dismiss(from: any SelectLocationViewable, with address: Address?) { 27 | guard let view = from as? UIViewController else { return } 28 | delegate?.didDismiss(router: self, address: address) 29 | dismiss(from: view, animated: true) 30 | } 31 | func showAlert( 32 | from view: any SelectLocationViewable, 33 | title: String, 34 | message: String 35 | ) { 36 | guard let view = view as? UIViewController else { return } 37 | let alertVC: UIAlertController = UIAlertController( 38 | title: title, 39 | message: message, 40 | preferredStyle: .alert 41 | ) 42 | let alertAction: UIAlertAction = UIAlertAction(title: "확인", style: .default) 43 | alertVC.addAction(alertAction) 44 | present(from: view, with: alertVC, animated: true) 45 | } 46 | } 47 | 48 | extension SelectLocationRouter: SelectLocationModuleBuildable {} 49 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Walk/RequestWalk/View/CardPresentationController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CardPresentationController.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/18/24. 6 | // 7 | import UIKit 8 | 9 | final class CardPresentationController: UIPresentationController { 10 | override var frameOfPresentedViewInContainerView: CGRect { 11 | // 중앙에 카드처럼 보이게 할 크기 지정 12 | guard let containerView else { return CGRect.zero } 13 | let width: CGFloat = containerView.bounds.width * 0.9 14 | let height: CGFloat = containerView.bounds.height * 0.8 15 | let xValue = (containerView.bounds.width - width) / 2 16 | let yValue = (containerView.bounds.height - height) / 2 17 | return CGRect(x: xValue, y: yValue, width: width, height: height) 18 | } 19 | 20 | override func presentationTransitionWillBegin() { 21 | super.presentationTransitionWillBegin() 22 | 23 | if let containerView = containerView { 24 | let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) 25 | blurEffectView.frame = containerView.bounds 26 | containerView.addSubview(blurEffectView) 27 | blurEffectView.alpha = 0 28 | 29 | // 애니메이션을 통해 흐림 효과가 나타나도록 설정 30 | if let coordinator = presentingViewController.transitionCoordinator { 31 | coordinator.animate(alongsideTransition: { context in 32 | blurEffectView.alpha = 0.5 33 | }) 34 | } 35 | } 36 | } 37 | 38 | override func dismissalTransitionWillBegin() { 39 | super.dismissalTransitionWillBegin() 40 | 41 | // dismiss 할 때 흐림 효과가 사라지도록 애니메이션 설정 42 | if let containerView, 43 | let blurEffectView = containerView.subviews.first(where: { $0 is UIVisualEffectView }), 44 | let coordinator = presentingViewController.transitionCoordinator { 45 | coordinator.animate(alongsideTransition: { context in 46 | blurEffectView.alpha = 0 47 | }) 48 | } 49 | } 50 | 51 | override func containerViewWillLayoutSubviews() { 52 | super.containerViewWillLayoutSubviews() 53 | 54 | if let presentedView { 55 | presentedView.layer.cornerRadius = 20 56 | presentedView.clipsToBounds = true 57 | 58 | presentedView.layer.shadowColor = SNMColor.black.cgColor 59 | presentedView.layer.shadowOpacity = 0.25 60 | presentedView.layer.shadowOffset = CGSize(width: 0, height: 4) 61 | presentedView.layer.shadowRadius = 4 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Walk/RequestWalk/View/LocationSelectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationSelectionView.swift 3 | // SniffMeet 4 | // 5 | // Created by 윤지성 on 11/19/24. 6 | // 7 | 8 | import Combine 9 | import UIKit 10 | 11 | final class LocationSelectionView: BaseView { 12 | let tapPublisher = PassthroughSubject() 13 | private var tapGesture = UITapGestureRecognizer() 14 | var locationString: String { 15 | locationLabel.text ?? "" 16 | } 17 | 18 | private var locationGuideLabel: UILabel = { 19 | let label = UILabel() 20 | label.text = Context.locationGuideTitle 21 | label.font = SNMFont.subheadline 22 | label.textColor = SNMColor.mainNavy 23 | return label 24 | }() 25 | 26 | private var locationLabel: UILabel = { 27 | let label = UILabel() 28 | label.font = SNMFont.subheadline 29 | label.textColor = SNMColor.subGray2 30 | return label 31 | }() 32 | 33 | private var chevronImageView: UIImageView = { 34 | let imageView = UIImageView() 35 | imageView.image = UIImage(systemName: "chevron.right") 36 | imageView.tintColor = SNMColor.subGray2 37 | return imageView 38 | }() 39 | 40 | override func configureAttributes() { 41 | addGestureRecognizer(tapGesture) 42 | tapGesture.addTarget(self, action: #selector(handleTapGesture)) 43 | } 44 | 45 | override func configureHierarchy() { 46 | [locationGuideLabel, locationLabel, chevronImageView].forEach{ 47 | addSubview($0) 48 | $0.translatesAutoresizingMaskIntoConstraints = false 49 | } 50 | addGestureRecognizer(tapGesture) 51 | 52 | } 53 | override func configureConstraints() { 54 | NSLayoutConstraint.activate([ 55 | locationGuideLabel.leadingAnchor.constraint(equalTo: leadingAnchor), 56 | locationLabel.trailingAnchor.constraint( 57 | equalTo: chevronImageView.leadingAnchor, 58 | constant: -LayoutConstant.navigationItemSpacing), 59 | chevronImageView.trailingAnchor.constraint(equalTo: trailingAnchor) 60 | ]) 61 | [locationGuideLabel, locationLabel, chevronImageView].forEach{ 62 | $0.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true 63 | } 64 | } 65 | 66 | @objc private func handleTapGesture() { 67 | tapPublisher.send() 68 | } 69 | 70 | func setAddress(address: String) { 71 | locationLabel.text = address 72 | } 73 | 74 | } 75 | private extension LocationSelectionView { 76 | enum Context { 77 | static let locationGuideTitle: String = "장소 선택" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Walk/RespondWalk/Interactor/ProcessedWalkInteractor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProcessedWalkInteractor.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 12/4/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol ProcessedWalkInteractable: AnyObject { 11 | var presenter: (any ProcessedWalkInteractorOutput)? { get set } 12 | func fetchSenderInfo(userId: UUID) 13 | func fetchProfileImage(urlString: String) 14 | func convertLocationToText(latitude: Double, longtitude: Double) 15 | } 16 | 17 | final class ProcessedWalkInteractor: ProcessedWalkInteractable { 18 | weak var presenter: (any ProcessedWalkInteractorOutput)? 19 | private let convertLocationToTextUseCase: any ConvertLocationToTextUseCase 20 | private let requestUserInfoUseCase: RequestMateInfoUseCase 21 | private let requestProfileImageUseCase: RequestProfileImageUseCase 22 | 23 | init( 24 | presenter: (any ProcessedWalkInteractorOutput)? = nil, 25 | convertLocationToTextUseCase: any ConvertLocationToTextUseCase, 26 | requestUserInfoUseCase: any RequestMateInfoUseCase, 27 | requestProfileImageUseCase: any RequestProfileImageUseCase 28 | ) { 29 | self.presenter = presenter 30 | self.convertLocationToTextUseCase = convertLocationToTextUseCase 31 | self.requestUserInfoUseCase = requestUserInfoUseCase 32 | self.requestProfileImageUseCase = requestProfileImageUseCase 33 | } 34 | 35 | func fetchSenderInfo(userId: UUID) { 36 | Task { 37 | do { 38 | guard let senderInfo = try await requestUserInfoUseCase.execute( 39 | mateId: userId 40 | ) else { 41 | presenter?.didFailToFetchWalkRequest( 42 | error: SupabaseError.notFound 43 | ) 44 | return 45 | } 46 | presenter?.didFetchUserInfo(senderInfo: senderInfo) 47 | guard let profileImageURL = senderInfo.profileImageURL else { return } 48 | fetchProfileImage(urlString: profileImageURL) 49 | } catch { 50 | presenter?.didFailToFetchWalkRequest(error: error) 51 | } 52 | } 53 | } 54 | func fetchProfileImage(urlString: String) { 55 | Task { [weak self] in 56 | let imageData = try await self?.requestProfileImageUseCase.execute(fileName: urlString) 57 | self?.presenter?.didFetchProfileImage(with: imageData) 58 | } 59 | } 60 | func convertLocationToText(latitude: Double, longtitude: Double) { 61 | Task { 62 | let locationText: String? = await convertLocationToTextUseCase.execute( 63 | latitude: latitude, longtitude: longtitude 64 | ) 65 | presenter?.didConvertLocationToText(with: locationText) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Walk/RespondWalk/Presenter/RespondMapPresenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RespondMapPresenter.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/30/24. 6 | // 7 | 8 | import Combine 9 | 10 | protocol RespondMapPresentable: AnyObject { 11 | var view: (any RespondMapViewable)? { get set } 12 | var router: (any RespondMapRoutable)? { get set } 13 | var output: (any RespondMapPresenterOutput) { get } 14 | 15 | func didTapDismissButton() 16 | } 17 | 18 | final class RespondMapPresenter: RespondMapPresentable { 19 | var view: (any RespondMapViewable)? 20 | var router: (any RespondMapRoutable)? 21 | var output: (any RespondMapPresenterOutput) 22 | 23 | init( 24 | address: Address, 25 | view: (any RespondMapViewable)? = nil, 26 | router: (any RespondMapRoutable)? = nil 27 | ) { 28 | self.view = view 29 | self.router = router 30 | self.output = DefaultRespondMapPresenterOutput( 31 | selectedLocation: Just(address) 32 | ) 33 | } 34 | func didTapDismissButton() { 35 | guard let view else { return } 36 | router?.dismiss(view: view) 37 | } 38 | } 39 | 40 | // MARK: - ResponsdMapPresenterOutput 41 | 42 | protocol RespondMapPresenterOutput { 43 | var selectedLocation: Just
{ get } 44 | } 45 | 46 | struct DefaultRespondMapPresenterOutput: RespondMapPresenterOutput { 47 | var selectedLocation: Just
48 | } 49 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Walk/RespondWalk/Router/ProcessedWalkRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProcessedWalkRouter.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 12/4/24. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol ProcessedWalkRoutable: Routable { 11 | func dismiss(view: any ProcessedWalkViewable) 12 | func showSelectedLocationMapView(view: any ProcessedWalkViewable, address: Address) 13 | } 14 | 15 | final class ProcessedWalkRouter: ProcessedWalkRoutable { 16 | func dismiss(view: any ProcessedWalkViewable) { 17 | guard let view = view as? UIViewController else { return } 18 | dismiss(from: view, animated: true) 19 | } 20 | func showSelectedLocationMapView(view: any ProcessedWalkViewable, address: Address) { 21 | guard let view = view as? UIViewController else { return } 22 | let selectedLocationView = RespondMapRouter.createRespondMapView(address: address) 23 | fullScreen(from: view, with: selectedLocationView, animated: true) 24 | } 25 | } 26 | 27 | extension ProcessedWalkRouter: ProcessedWalkModuleBuildable {} 28 | 29 | // MARK: - ProcessedWalkModuleBuildable 30 | 31 | protocol ProcessedWalkModuleBuildable { 32 | static func createProcessedWalkView(noti: WalkNoti) -> UIViewController 33 | } 34 | 35 | extension ProcessedWalkModuleBuildable { 36 | static func createProcessedWalkView(noti: WalkNoti) -> UIViewController { 37 | let view = ProcessedWalkViewController() 38 | let presenter = ProcessedWalkPresenter(noti: noti) 39 | let interactor = ProcessedWalkInteractor( 40 | convertLocationToTextUseCase: ConvertLocationToTextUseCaseImpl(), 41 | requestUserInfoUseCase: RequestMateInfoUsecaseImpl(), 42 | requestProfileImageUseCase: RequestProfileImageUseCaseImpl( 43 | remoteImageManager: SupabaseStorageManager( 44 | networkProvider: SNMNetworkProvider() 45 | ), 46 | cacheManager: ImageNSCacheManager.shared 47 | ) 48 | ) 49 | let router = ProcessedWalkRouter() 50 | 51 | view.presenter = presenter 52 | presenter.view = view 53 | presenter.interactor = interactor 54 | presenter.router = router 55 | interactor.presenter = presenter 56 | 57 | return view 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Walk/RespondWalk/Router/RespondMapRouter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RespondMapRouter.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/30/24. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol RespondMapRoutable: Routable, AnyObject { 11 | var presenter: (any RespondMapPresentable)? { get set } 12 | 13 | func dismiss(view: any RespondMapViewable) 14 | } 15 | 16 | final class RespondMapRouter: RespondMapRoutable { 17 | var presenter: (any RespondMapPresentable)? 18 | 19 | func dismiss(view: any RespondMapViewable) { 20 | guard let view = view as? UIViewController else { return } 21 | dismiss(from: view, animated: true) 22 | } 23 | } 24 | 25 | extension RespondMapRouter: RespondMapModuleBuildable {} 26 | 27 | // MARK: - RespondMapModuleBuildable 28 | 29 | protocol RespondMapModuleBuildable { 30 | static func createRespondMapView(address: Address) -> UIViewController 31 | } 32 | 33 | extension RespondMapModuleBuildable { 34 | static func createRespondMapView(address: Address) -> UIViewController { 35 | let view: RespondMapViewController = RespondMapViewController() 36 | let presenter: any RespondMapPresentable = RespondMapPresenter(address: address) 37 | let router: any RespondMapRoutable = RespondMapRouter() 38 | 39 | view.presenter = presenter 40 | presenter.view = view 41 | presenter.router = router 42 | router.presenter = presenter 43 | 44 | return view 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /SniffMeet/SniffMeet/Source/Walk/WalkLog/WalkLogList/View/WalkLogListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WalkLogListViewController.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/24/24. 6 | // 7 | 8 | import UIKit 9 | 10 | final class WalkLogListViewController: BaseViewController { 11 | private let walkLogTableView: UITableView = UITableView() 12 | 13 | override func configureAttributes() { 14 | walkLogTableView.dataSource = self 15 | walkLogTableView.delegate = self 16 | walkLogTableView.separatorInset = .zero 17 | } 18 | override func configureHierachy() { 19 | [walkLogTableView].forEach { 20 | $0.translatesAutoresizingMaskIntoConstraints = false 21 | view.addSubview($0) 22 | } 23 | } 24 | override func configureConstraints() { 25 | NSLayoutConstraint.activate([ 26 | walkLogTableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), 27 | walkLogTableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), 28 | walkLogTableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 29 | walkLogTableView.trailingAnchor.constraint(equalTo: view.trailingAnchor) 30 | ]) 31 | } 32 | } 33 | 34 | //MARK: - WalkLogListViewController+UITableView DataSource, Delegate 35 | 36 | extension WalkLogListViewController: UITableViewDataSource { 37 | func tableView( 38 | _ tableView: UITableView, 39 | numberOfRowsInSection section: Int 40 | ) -> Int { 41 | 3 42 | } 43 | 44 | func tableView( 45 | _ tableView: UITableView, 46 | cellForRowAt indexPath: IndexPath 47 | ) -> UITableViewCell { 48 | // FIXME: 실제 데이터로 변경 필요 49 | WalkLogCell( 50 | dogInfo: .init( 51 | id: UUID(uuidString: "") ?? DogProfileDTO.example.id, 52 | name: "후추추", 53 | keywords: [], 54 | profileImage: nil 55 | ), 56 | walkLog: .init( 57 | step: 100, 58 | distance: 1002.2, 59 | startDate: .now, 60 | endDate: .now, 61 | image: nil 62 | ), 63 | style: .default, 64 | reuseIdentifier: WalkLogCell.identifier 65 | ) 66 | } 67 | } 68 | 69 | extension WalkLogListViewController: UITableViewDelegate { 70 | func tableView( 71 | _ tableView: UITableView, 72 | heightForRowAt indexPath: IndexPath 73 | ) -> CGFloat { 74 | 393 75 | } 76 | func tableView( 77 | _ tableView: UITableView, 78 | didSelectRowAt indexPath: IndexPath 79 | ) { 80 | tableView.deselectRow(at: indexPath, animated: true) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /SniffMeet/SupabaseTests/SupabaseStorageTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SupabaseStorageTests.swift 3 | // SniffMeet 4 | // 5 | // Created by sole on 11/24/24. 6 | // 7 | 8 | import UIKit 9 | import XCTest 10 | 11 | final class SupabaseStorageTests: XCTestCase { 12 | private var storageManager: (any RemoteImageManagable)! 13 | 14 | override func setUp() { 15 | self.storageManager = SupabaseStorageManager( 16 | networkProvider: SNMNetworkProvider() 17 | ) 18 | } 19 | 20 | func test_이미지_다운로드후_이미지_변환에_성공해야_한다() async throws { 21 | // given 22 | // when 23 | let imageData = try await storageManager.download(fileName: "8AA2442D-1E09-41BC-BE92-50AC65C19367") 24 | // then 25 | XCTAssertNotNil(UIImage(data: imageData)) 26 | } 27 | func test_이미지를_데이터로_변환후_업로드에_성공해야_한다() async throws { 28 | // given 29 | let image = UIImage(systemName: "square.and.arrow.up.fill")! 30 | let imageData = image.jpegData(compressionQuality: 1)! 31 | try await storageManager.upload(imageData: imageData, fileName: UUID().uuidString, mimeType: .image) 32 | } 33 | } 34 | --------------------------------------------------------------------------------