├── .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 |
19 |
--------------------------------------------------------------------------------
/SniffMeet/SniffMeet/Resource/Assets.xcassets/AppImage.imageset/Frame 122.svg:
--------------------------------------------------------------------------------
1 |
19 |
--------------------------------------------------------------------------------
/SniffMeet/SniffMeet/Resource/Assets.xcassets/AppImage.imageset/Frame 123.svg:
--------------------------------------------------------------------------------
1 |
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 |
--------------------------------------------------------------------------------