diff --git a/CPR2U-iOS/.github/ISSUE_TEMPLATE/default-issue-template.md b/CPR2U-iOS/.github/ISSUE_TEMPLATE/default-issue-template.md new file mode 100644 index 0000000..f7af43f --- /dev/null +++ b/CPR2U-iOS/.github/ISSUE_TEMPLATE/default-issue-template.md @@ -0,0 +1,17 @@ +--- +name: Default Issue Template +about: Default Template +title: "[작업태그]" +labels: '' +assignees: jeong-hyeonHwang + +--- + +## 문제 상황 +*어떤 문제가 발생했나요?* + +## 반영 형태 및 SEQUENCE +*어떤 식으로 반영할 것인지, 어떤 과정을 거쳐서 해결할건지* + +## TODO +- [ ] Branch 파기 diff --git a/CPR2U-iOS/.github/workflows/Build.yml b/CPR2U-iOS/.github/workflows/Build.yml new file mode 100644 index 0000000..2a52114 --- /dev/null +++ b/CPR2U-iOS/.github/workflows/Build.yml @@ -0,0 +1,20 @@ +name: Build + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: macos-latest + + steps: + - uses: actions/checkout@v3 + + - name: Build Xcode + run: | + pod install --repo-update --clean-install --project-directory=CPR2U/ + xcodebuild build -workspace CPR2U/CPR2U.xcworkspace -scheme CPR2U-Workspace -destination 'platform=iOS Simulator,name=iPhone 14,OS=latest' diff --git a/CPR2U-iOS/.gitignore b/CPR2U-iOS/.gitignore new file mode 100644 index 0000000..3fcfb32 --- /dev/null +++ b/CPR2U-iOS/.gitignore @@ -0,0 +1,94 @@ +CPR2U/CPR2U/GoogleService-Info.plist +CPR2U/CPR2U/Private.plist +Pods/ +.DS_Store +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ diff --git a/CPR2U-iOS/CPR2U/CPR2U.xcodeproj/project.pbxproj b/CPR2U-iOS/CPR2U/CPR2U.xcodeproj/project.pbxproj new file mode 100644 index 0000000..0d62574 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U.xcodeproj/project.pbxproj @@ -0,0 +1,1171 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 24CEFDC9C69B23440E07EF1C /* Pods_CPR2U.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EDF82C0EF3B44D7FED6D1535 /* Pods_CPR2U.framework */; }; + 3A22BF4329BC9122004411BE /* URLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22BF4229BC9122004411BE /* URLs.swift */; }; + 3A22BF5029BCCE07004411BE /* AuthEndPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22BF4F29BCCE07004411BE /* AuthEndPoint.swift */; }; + 3A22BF5229BCD6B8004411BE /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22BF5129BCD6B8004411BE /* AuthManager.swift */; }; + 3A22BF5529BCEAC8004411BE /* NetworkResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22BF5429BCEAC8004411BE /* NetworkResponse.swift */; }; + 3A22BF5729BCEBA2004411BE /* APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22BF5629BCEBA2004411BE /* APIError.swift */; }; + 3A22BF5D29BCF159004411BE /* Auth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22BF5C29BCF159004411BE /* Auth.swift */; }; + 3A22BF5F29BDFF1A004411BE /* NetworkRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22BF5E29BDFF1A004411BE /* NetworkRequest.swift */; }; + 3A22BF6129BDFF3B004411BE /* HttpMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22BF6029BDFF3B004411BE /* HttpMethod.swift */; }; + 3A22BF6529BE01AB004411BE /* EndPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22BF6429BE01AB004411BE /* EndPoint.swift */; }; + 3A22BF6729BE05D9004411BE /* Encodable+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22BF6629BE05D9004411BE /* Encodable+.swift */; }; + 3A22BF6929BE0A87004411BE /* Requestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22BF6829BE0A87004411BE /* Requestable.swift */; }; + 3A324CD029C4BCCF00165E2E /* OXQuizChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A324CCF29C4BCCF00165E2E /* OXQuizChoiceView.swift */; }; + 3A324CD229C4BCE000165E2E /* MultiQuizChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A324CD129C4BCE000165E2E /* MultiQuizChoiceView.swift */; }; + 3A324CD829C60E6400165E2E /* movenet_thunder.tflite in Resources */ = {isa = PBXBuildFile; fileRef = 3A324CD629C60E6400165E2E /* movenet_thunder.tflite */; }; + 3A324CDB29C60E8F00165E2E /* PoseConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A324CDA29C60E8F00165E2E /* PoseConfig.swift */; }; + 3A324CDD29C60E9800165E2E /* MoveNet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A324CDC29C60E9800165E2E /* MoveNet.swift */; }; + 3A324CDF29C60E9F00165E2E /* PoseData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A324CDE29C60E9F00165E2E /* PoseData.swift */; }; + 3A324CE129C60EAC00165E2E /* PoseEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A324CE029C60EAC00165E2E /* PoseEstimator.swift */; }; + 3A324CE329C6103800165E2E /* CGSize+TFLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A324CE229C6103800165E2E /* CGSize+TFLite.swift */; }; + 3A324CE529C6104400165E2E /* CVPixelBuffer+TFLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A324CE429C6104400165E2E /* CVPixelBuffer+TFLite.swift */; }; + 3A324CE729C6104D00165E2E /* Data+TFLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A324CE629C6104D00165E2E /* Data+TFLite.swift */; }; + 3A324CEA29C6111200165E2E /* CameraFeedManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A324CE929C6111200165E2E /* CameraFeedManager.swift */; }; + 3A324CEC29C611AB00165E2E /* CameraOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A324CEB29C611AB00165E2E /* CameraOverlayView.swift */; }; + 3A396B8F29C89DA3009B0545 /* EducationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396B8E29C89DA3009B0545 /* EducationViewModel.swift */; }; + 3A396B9129C8A87D009B0545 /* ViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396B9029C8A87D009B0545 /* ViewModelProtocol.swift */; }; + 3A396B9329C97E31009B0545 /* UIView+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396B9229C97E31009B0545 /* UIView+.swift */; }; + 3A396B9B29C9D395009B0545 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 3A396B9A29C9D395009B0545 /* FirebaseMessaging */; }; + 3A396BA129C9E906009B0545 /* AutoLoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BA029C9E906009B0545 /* AutoLoginViewController.swift */; }; + 3A396BA329C9EDAB009B0545 /* UserDefaultsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BA229C9EDAB009B0545 /* UserDefaultsManager.swift */; }; + 3A396BA529CA0646009B0545 /* UIWindow+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BA429CA0646009B0545 /* UIWindow+.swift */; }; + 3A396BAA29CA0A07009B0545 /* TabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BA929CA0A07009B0545 /* TabBarViewController.swift */; }; + 3A396BB229CAF0DA009B0545 /* DeviceTokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BB129CAF0DA009B0545 /* DeviceTokenManager.swift */; }; + 3A396BB529CB1F31009B0545 /* EducationEndPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BB429CB1F31009B0545 /* EducationEndPoint.swift */; }; + 3A396BB729CB1F38009B0545 /* EducationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BB629CB1F38009B0545 /* EducationManager.swift */; }; + 3A396BB929CB2686009B0545 /* Education.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BB829CB2686009B0545 /* Education.swift */; }; + 3A396BC329CCA563009B0545 /* LectureViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BC229CCA563009B0545 /* LectureViewController.swift */; }; + 3A396BC629CF14AB009B0545 /* CallMainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BC529CF14AB009B0545 /* CallMainViewController.swift */; }; + 3A396BC829CF14D5009B0545 /* DispatchWaitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BC729CF14D5009B0545 /* DispatchWaitViewController.swift */; }; + 3A396BCA29CF16DF009B0545 /* ApproachNoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BC929CF16DF009B0545 /* ApproachNoticeView.swift */; }; + 3A396BCC29CF204F009B0545 /* EmergencyDescriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BCB29CF204F009B0545 /* EmergencyDescriptionView.swift */; }; + 3A396BCE29CF278E009B0545 /* CurrentLocationNoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BCD29CF278E009B0545 /* CurrentLocationNoticeView.swift */; }; + 3A396BD129CF2BA2009B0545 /* CallCircleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BD029CF2BA2009B0545 /* CallCircleView.swift */; }; + 3A396BD329CF3301009B0545 /* TimeCounterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BD229CF3301009B0545 /* TimeCounterView.swift */; }; + 3A396BD529CF4A0E009B0545 /* CallViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BD429CF4A0E009B0545 /* CallViewModel.swift */; }; + 3A396BD929CF644A009B0545 /* Int+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A396BD829CF644A009B0545 /* Int+.swift */; }; + 3A50F39329BF51F800DC1E29 /* UIButton+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A50F39229BF51F800DC1E29 /* UIButton+.swift */; }; + 3A52C73229D5F31D005C6E40 /* DispatchTimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A52C73129D5F31D005C6E40 /* DispatchTimerView.swift */; }; + 3A5C390E29C0844700378015 /* CombineCocoa in Frameworks */ = {isa = PBXBuildFile; productRef = 3A5C390D29C0844700378015 /* CombineCocoa */; }; + 3A5C391029C08C8900378015 /* QuizViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A5C390F29C08C8900378015 /* QuizViewModel.swift */; }; + 3A5C391329C0948F00378015 /* Quiz.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A5C391229C0948F00378015 /* Quiz.swift */; }; + 3A70255E29D0869200225F56 /* MapManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A70255D29D0869200225F56 /* MapManager.swift */; }; + 3A70256B29D4292E00225F56 /* AddressEndPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A70256A29D4292E00225F56 /* AddressEndPoint.swift */; }; + 3A70256D29D4293800225F56 /* AddressManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A70256C29D4293800225F56 /* AddressManager.swift */; }; + 3A70256F29D4293E00225F56 /* Address.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A70256E29D4293E00225F56 /* Address.swift */; }; + 3A70257329D42DB300225F56 /* AddressSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A70257229D42DB300225F56 /* AddressSettingView.swift */; }; + 3A70258529D4C12100225F56 /* CPR_Posture_Sound.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 3A70258429D4C12100225F56 /* CPR_Posture_Sound.mp3 */; }; + 3A70258929D5BD9A00225F56 /* DispatchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A70258829D5BD9A00225F56 /* DispatchViewController.swift */; }; + 3A70258B29D5BFA300225F56 /* DispatchDescriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A70258A29D5BFA300225F56 /* DispatchDescriptionView.swift */; }; + 3A70259029D5CAF300225F56 /* DispatchEndPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A70258F29D5CAF300225F56 /* DispatchEndPoint.swift */; }; + 3A70259229D5CAFE00225F56 /* DispatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A70259129D5CAFE00225F56 /* DispatchManager.swift */; }; + 3A70259429D5CC6100225F56 /* Dispatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A70259329D5CC6100225F56 /* Dispatch.swift */; }; + 3A70259629D5E07700225F56 /* DispatchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A70259529D5E07700225F56 /* DispatchViewModel.swift */; }; + 3A8C31A129D6042B004A4B84 /* MypageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8C31A029D6042B004A4B84 /* MypageViewController.swift */; }; + 3A8C31A429D6062E004A4B84 /* MypageStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8C31A329D6062E004A4B84 /* MypageStatusView.swift */; }; + 3A8C31AB29D62FF1004A4B84 /* ReportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8C31AA29D62FF1004A4B84 /* ReportViewController.swift */; }; + 3A8C31AE29D6C9FC004A4B84 /* MypageTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8C31AD29D6C9FC004A4B84 /* MypageTableViewCell.swift */; }; + 3A8C31B829D6FAAB004A4B84 /* CPR_Sound.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 3A8C31B729D6FAAB004A4B84 /* CPR_Sound.mp3 */; }; + 3A91C2CC29D0392B00028003 /* Call.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A91C2CB29D0392B00028003 /* Call.swift */; }; + 3A91C2CF29D0396500028003 /* CallEndPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A91C2CE29D0396500028003 /* CallEndPoint.swift */; }; + 3A91C2D129D0396E00028003 /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A91C2D029D0396E00028003 /* CallManager.swift */; }; + 3A91C2D329D03FCC00028003 /* CallerLocationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A91C2D229D03FCC00028003 /* CallerLocationInfo.swift */; }; + 3AA636AE29AE5055006BB5EF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA636AD29AE5055006BB5EF /* AppDelegate.swift */; }; + 3AA636B029AE5055006BB5EF /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA636AF29AE5055006BB5EF /* SceneDelegate.swift */; }; + 3AA636B729AE5057006BB5EF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3AA636B629AE5057006BB5EF /* Assets.xcassets */; }; + 3AA636BA29AE5057006BB5EF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3AA636B829AE5057006BB5EF /* LaunchScreen.storyboard */; }; + 3AA636C229B07C5F006BB5EF /* UIColor+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA636C129B07C5F006BB5EF /* UIColor+.swift */; }; + 3AA636C829B08402006BB5EF /* NotoSans-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 3AA636C629B08402006BB5EF /* NotoSans-Bold.ttf */; }; + 3AA636C929B08402006BB5EF /* NotoSans-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 3AA636C729B08402006BB5EF /* NotoSans-Regular.ttf */; }; + 3AA636CD29B0AB17006BB5EF /* PhoneNumberVertificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA636CC29B0AB17006BB5EF /* PhoneNumberVertificationViewController.swift */; }; + 3AA636D329B24D72006BB5EF /* UIFont+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA636D229B24D72006BB5EF /* UIFont+.swift */; }; + 3AA636D529B358D0006BB5EF /* SMSCodeVertificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA636D429B358D0006BB5EF /* SMSCodeVertificationViewController.swift */; }; + 3AA636D729B35CA3006BB5EF /* SMSCodeInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA636D629B35CA3006BB5EF /* SMSCodeInputView.swift */; }; + 3AA636D929B36C76006BB5EF /* NicknameVertificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA636D829B36C76006BB5EF /* NicknameVertificationViewController.swift */; }; + 3AA636ED29B5E816006BB5EF /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA636EC29B5E816006BB5EF /* AuthViewModel.swift */; }; + 3AA636F129B5F1C8006BB5EF /* UIControl+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA636F029B5F1C8006BB5EF /* UIControl+.swift */; }; + 3AA636F329B5F20D006BB5EF /* UITextField+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA636F229B5F20D006BB5EF /* UITextField+.swift */; }; + 3AA636F529B61D67006BB5EF /* UIViewController+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA636F429B61D67006BB5EF /* UIViewController+.swift */; }; + 3ADE724F29B9D4AB00EEE19C /* Constraints.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADE724E29B9D4AB00EEE19C /* Constraints.swift */; }; + 3ADE725229B9D55100EEE19C /* EducationMainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADE725129B9D55100EEE19C /* EducationMainViewController.swift */; }; + 3ADE725429B9D69D00EEE19C /* CertificateStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADE725329B9D69D00EEE19C /* CertificateStatusView.swift */; }; + 3ADE725629B9EA3200EEE19C /* EducationProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADE725529B9EA3200EEE19C /* EducationProgressView.swift */; }; + 3ADE725A29BA333D00EEE19C /* EducationCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADE725929BA333D00EEE19C /* EducationCollectionViewCell.swift */; }; + 3ADE726029BB03A100EEE19C /* EducationQuizViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADE725F29BB03A100EEE19C /* EducationQuizViewController.swift */; }; + 3ADE726229BB047300EEE19C /* QuizChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADE726129BB047300EEE19C /* QuizChoiceView.swift */; }; + 3ADE726629BB04C900EEE19C /* QuizQuestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADE726529BB04C900EEE19C /* QuizQuestionView.swift */; }; + 3ADE726829BB21BE00EEE19C /* CustomNoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADE726729BB21BE00EEE19C /* CustomNoticeView.swift */; }; + 3ADE726C29BB536300EEE19C /* PracticeExplainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADE726B29BB536300EEE19C /* PracticeExplainViewController.swift */; }; + 3ADE726E29BB59C000EEE19C /* PosePracticeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADE726D29BB59C000EEE19C /* PosePracticeViewController.swift */; }; + 3ADE727029BB630F00EEE19C /* PosePracticeResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADE726F29BB630F00EEE19C /* PosePracticeResultViewController.swift */; }; + 3ADE727229BB64D400EEE19C /* EvaluationResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADE727129BB64D400EEE19C /* EvaluationResultView.swift */; }; + 3ADE727629BB78BF00EEE19C /* ScoreResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ADE727529BB78BF00EEE19C /* ScoreResultView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2B9F1310FB54413A2CCE87FD /* Pods-CPR2U.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CPR2U.debug.xcconfig"; path = "Target Support Files/Pods-CPR2U/Pods-CPR2U.debug.xcconfig"; sourceTree = ""; }; + 3A22BF4229BC9122004411BE /* URLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLs.swift; sourceTree = ""; }; + 3A22BF4F29BCCE07004411BE /* AuthEndPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthEndPoint.swift; sourceTree = ""; }; + 3A22BF5129BCD6B8004411BE /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; + 3A22BF5429BCEAC8004411BE /* NetworkResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkResponse.swift; sourceTree = ""; }; + 3A22BF5629BCEBA2004411BE /* APIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIError.swift; sourceTree = ""; }; + 3A22BF5C29BCF159004411BE /* Auth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Auth.swift; sourceTree = ""; }; + 3A22BF5E29BDFF1A004411BE /* NetworkRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkRequest.swift; sourceTree = ""; }; + 3A22BF6029BDFF3B004411BE /* HttpMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpMethod.swift; sourceTree = ""; }; + 3A22BF6429BE01AB004411BE /* EndPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndPoint.swift; sourceTree = ""; }; + 3A22BF6629BE05D9004411BE /* Encodable+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Encodable+.swift"; sourceTree = ""; }; + 3A22BF6829BE0A87004411BE /* Requestable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Requestable.swift; sourceTree = ""; }; + 3A324CCF29C4BCCF00165E2E /* OXQuizChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OXQuizChoiceView.swift; sourceTree = ""; }; + 3A324CD129C4BCE000165E2E /* MultiQuizChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiQuizChoiceView.swift; sourceTree = ""; }; + 3A324CD629C60E6400165E2E /* movenet_thunder.tflite */ = {isa = PBXFileReference; lastKnownFileType = file; path = movenet_thunder.tflite; sourceTree = ""; }; + 3A324CDA29C60E8F00165E2E /* PoseConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PoseConfig.swift; sourceTree = ""; }; + 3A324CDC29C60E9800165E2E /* MoveNet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveNet.swift; sourceTree = ""; }; + 3A324CDE29C60E9F00165E2E /* PoseData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PoseData.swift; sourceTree = ""; }; + 3A324CE029C60EAC00165E2E /* PoseEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PoseEstimator.swift; sourceTree = ""; }; + 3A324CE229C6103800165E2E /* CGSize+TFLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGSize+TFLite.swift"; sourceTree = ""; }; + 3A324CE429C6104400165E2E /* CVPixelBuffer+TFLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CVPixelBuffer+TFLite.swift"; sourceTree = ""; }; + 3A324CE629C6104D00165E2E /* Data+TFLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+TFLite.swift"; sourceTree = ""; }; + 3A324CE929C6111200165E2E /* CameraFeedManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraFeedManager.swift; sourceTree = ""; }; + 3A324CEB29C611AB00165E2E /* CameraOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraOverlayView.swift; sourceTree = ""; }; + 3A396B8E29C89DA3009B0545 /* EducationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EducationViewModel.swift; sourceTree = ""; }; + 3A396B9029C8A87D009B0545 /* ViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProtocol.swift; sourceTree = ""; }; + 3A396B9229C97E31009B0545 /* UIView+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+.swift"; sourceTree = ""; }; + 3A396B9629C9C648009B0545 /* CPR2U.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CPR2U.entitlements; sourceTree = ""; }; + 3A396BA029C9E906009B0545 /* AutoLoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoLoginViewController.swift; sourceTree = ""; }; + 3A396BA229C9EDAB009B0545 /* UserDefaultsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsManager.swift; sourceTree = ""; }; + 3A396BA429CA0646009B0545 /* UIWindow+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+.swift"; sourceTree = ""; }; + 3A396BA929CA0A07009B0545 /* TabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarViewController.swift; sourceTree = ""; }; + 3A396BAC29CA138A009B0545 /* Private.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Private.plist; sourceTree = ""; }; + 3A396BB129CAF0DA009B0545 /* DeviceTokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTokenManager.swift; sourceTree = ""; }; + 3A396BB429CB1F31009B0545 /* EducationEndPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EducationEndPoint.swift; sourceTree = ""; }; + 3A396BB629CB1F38009B0545 /* EducationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EducationManager.swift; sourceTree = ""; }; + 3A396BB829CB2686009B0545 /* Education.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Education.swift; sourceTree = ""; }; + 3A396BC229CCA563009B0545 /* LectureViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LectureViewController.swift; sourceTree = ""; }; + 3A396BC529CF14AB009B0545 /* CallMainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMainViewController.swift; sourceTree = ""; }; + 3A396BC729CF14D5009B0545 /* DispatchWaitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchWaitViewController.swift; sourceTree = ""; }; + 3A396BC929CF16DF009B0545 /* ApproachNoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApproachNoticeView.swift; sourceTree = ""; }; + 3A396BCB29CF204F009B0545 /* EmergencyDescriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmergencyDescriptionView.swift; sourceTree = ""; }; + 3A396BCD29CF278E009B0545 /* CurrentLocationNoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentLocationNoticeView.swift; sourceTree = ""; }; + 3A396BD029CF2BA2009B0545 /* CallCircleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CallCircleView.swift; path = CPR2U/Scene/Call/Main/CallCircleView.swift; sourceTree = SOURCE_ROOT; }; + 3A396BD229CF3301009B0545 /* TimeCounterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeCounterView.swift; sourceTree = ""; }; + 3A396BD429CF4A0E009B0545 /* CallViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallViewModel.swift; sourceTree = ""; }; + 3A396BD829CF644A009B0545 /* Int+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+.swift"; sourceTree = ""; }; + 3A50F39229BF51F800DC1E29 /* UIButton+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+.swift"; sourceTree = ""; }; + 3A52C73129D5F31D005C6E40 /* DispatchTimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchTimerView.swift; sourceTree = ""; }; + 3A5C390F29C08C8900378015 /* QuizViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizViewModel.swift; sourceTree = ""; }; + 3A5C391229C0948F00378015 /* Quiz.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quiz.swift; sourceTree = ""; }; + 3A70255D29D0869200225F56 /* MapManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapManager.swift; sourceTree = ""; }; + 3A70256A29D4292E00225F56 /* AddressEndPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressEndPoint.swift; sourceTree = ""; }; + 3A70256C29D4293800225F56 /* AddressManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressManager.swift; sourceTree = ""; }; + 3A70256E29D4293E00225F56 /* Address.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Address.swift; sourceTree = ""; }; + 3A70257229D42DB300225F56 /* AddressSettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressSettingView.swift; sourceTree = ""; }; + 3A70257929D4731B00225F56 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + 3A70258429D4C12100225F56 /* CPR_Posture_Sound.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = CPR_Posture_Sound.mp3; sourceTree = ""; }; + 3A70258829D5BD9A00225F56 /* DispatchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchViewController.swift; sourceTree = ""; }; + 3A70258A29D5BFA300225F56 /* DispatchDescriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchDescriptionView.swift; sourceTree = ""; }; + 3A70258F29D5CAF300225F56 /* DispatchEndPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchEndPoint.swift; sourceTree = ""; }; + 3A70259129D5CAFE00225F56 /* DispatchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchManager.swift; sourceTree = ""; }; + 3A70259329D5CC6100225F56 /* Dispatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dispatch.swift; sourceTree = ""; }; + 3A70259529D5E07700225F56 /* DispatchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchViewModel.swift; sourceTree = ""; }; + 3A8C31A029D6042B004A4B84 /* MypageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MypageViewController.swift; sourceTree = ""; }; + 3A8C31A329D6062E004A4B84 /* MypageStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MypageStatusView.swift; sourceTree = ""; }; + 3A8C31AA29D62FF1004A4B84 /* ReportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewController.swift; sourceTree = ""; }; + 3A8C31AD29D6C9FC004A4B84 /* MypageTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MypageTableViewCell.swift; sourceTree = ""; }; + 3A8C31B729D6FAAB004A4B84 /* CPR_Sound.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = CPR_Sound.mp3; sourceTree = ""; }; + 3A91C2CB29D0392B00028003 /* Call.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Call.swift; sourceTree = ""; }; + 3A91C2CE29D0396500028003 /* CallEndPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallEndPoint.swift; sourceTree = ""; }; + 3A91C2D029D0396E00028003 /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; + 3A91C2D229D03FCC00028003 /* CallerLocationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallerLocationInfo.swift; sourceTree = ""; }; + 3AA636AA29AE5055006BB5EF /* CPR2U.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CPR2U.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 3AA636AD29AE5055006BB5EF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 3AA636AF29AE5055006BB5EF /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 3AA636B629AE5057006BB5EF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 3AA636B929AE5057006BB5EF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 3AA636BB29AE5057006BB5EF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 3AA636C129B07C5F006BB5EF /* UIColor+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+.swift"; sourceTree = ""; }; + 3AA636C629B08402006BB5EF /* NotoSans-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-Bold.ttf"; sourceTree = ""; }; + 3AA636C729B08402006BB5EF /* NotoSans-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-Regular.ttf"; sourceTree = ""; }; + 3AA636CC29B0AB17006BB5EF /* PhoneNumberVertificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneNumberVertificationViewController.swift; sourceTree = ""; }; + 3AA636D229B24D72006BB5EF /* UIFont+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+.swift"; sourceTree = ""; }; + 3AA636D429B358D0006BB5EF /* SMSCodeVertificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMSCodeVertificationViewController.swift; sourceTree = ""; }; + 3AA636D629B35CA3006BB5EF /* SMSCodeInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMSCodeInputView.swift; sourceTree = ""; }; + 3AA636D829B36C76006BB5EF /* NicknameVertificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameVertificationViewController.swift; sourceTree = ""; }; + 3AA636EC29B5E816006BB5EF /* AuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewModel.swift; sourceTree = ""; }; + 3AA636F029B5F1C8006BB5EF /* UIControl+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIControl+.swift"; sourceTree = ""; }; + 3AA636F229B5F20D006BB5EF /* UITextField+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextField+.swift"; sourceTree = ""; }; + 3AA636F429B61D67006BB5EF /* UIViewController+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+.swift"; sourceTree = ""; }; + 3ADE724E29B9D4AB00EEE19C /* Constraints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constraints.swift; sourceTree = ""; }; + 3ADE725129B9D55100EEE19C /* EducationMainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EducationMainViewController.swift; sourceTree = ""; }; + 3ADE725329B9D69D00EEE19C /* CertificateStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateStatusView.swift; sourceTree = ""; }; + 3ADE725529B9EA3200EEE19C /* EducationProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EducationProgressView.swift; sourceTree = ""; }; + 3ADE725929BA333D00EEE19C /* EducationCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EducationCollectionViewCell.swift; sourceTree = ""; }; + 3ADE725F29BB03A100EEE19C /* EducationQuizViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EducationQuizViewController.swift; sourceTree = ""; }; + 3ADE726129BB047300EEE19C /* QuizChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizChoiceView.swift; sourceTree = ""; }; + 3ADE726529BB04C900EEE19C /* QuizQuestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizQuestionView.swift; sourceTree = ""; }; + 3ADE726729BB21BE00EEE19C /* CustomNoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomNoticeView.swift; sourceTree = ""; }; + 3ADE726B29BB536300EEE19C /* PracticeExplainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeExplainViewController.swift; sourceTree = ""; }; + 3ADE726D29BB59C000EEE19C /* PosePracticeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosePracticeViewController.swift; sourceTree = ""; }; + 3ADE726F29BB630F00EEE19C /* PosePracticeResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosePracticeResultViewController.swift; sourceTree = ""; }; + 3ADE727129BB64D400EEE19C /* EvaluationResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationResultView.swift; sourceTree = ""; }; + 3ADE727529BB78BF00EEE19C /* ScoreResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreResultView.swift; sourceTree = ""; }; + 8603206BC53B2CC2028A3EFB /* Pods-CPR2U.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CPR2U.release.xcconfig"; path = "Target Support Files/Pods-CPR2U/Pods-CPR2U.release.xcconfig"; sourceTree = ""; }; + EDF82C0EF3B44D7FED6D1535 /* Pods_CPR2U.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_CPR2U.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 3AA636A729AE5055006BB5EF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3A396B9B29C9D395009B0545 /* FirebaseMessaging in Frameworks */, + 3A5C390E29C0844700378015 /* CombineCocoa in Frameworks */, + 24CEFDC9C69B23440E07EF1C /* Pods_CPR2U.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 16C861399981B0664D00A6AF /* Pods */ = { + isa = PBXGroup; + children = ( + 2B9F1310FB54413A2CCE87FD /* Pods-CPR2U.debug.xcconfig */, + 8603206BC53B2CC2028A3EFB /* Pods-CPR2U.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 3A22BF3B29BC8C14004411BE /* Scene */ = { + isa = PBXGroup; + children = ( + 3A396BAB29CA0B58009B0545 /* Splash */, + 3A396BA829CA09DB009B0545 /* Main */, + 3AA636DE29B37E04006BB5EF /* Login */, + 3ADE725029B9D53E00EEE19C /* Education */, + 3A396BC429CF1487009B0545 /* Call */, + 3A70257C29D47A3200225F56 /* Dispatch */, + 3A8C319F29D603EA004A4B84 /* Mypage */, + ); + path = Scene; + sourceTree = ""; + }; + 3A22BF3C29BC8C3F004411BE /* Service */ = { + isa = PBXGroup; + children = ( + 3A22BF5329BCE990004411BE /* APIServices */, + 3A22BF4029BC90D6004411BE /* Network */, + ); + path = Service; + sourceTree = ""; + }; + 3A22BF3D29BC8C5D004411BE /* Util */ = { + isa = PBXGroup; + children = ( + 3A22BF4129BC90E7004411BE /* Constants */, + 3A22BF3E29BC8C62004411BE /* Extension */, + 3A396BAF29CAF001009B0545 /* Protocol */, + 3A396BB029CAF0B6009B0545 /* Shared */, + 3A396BAE29CAEFF0009B0545 /* Wrapper */, + ); + path = Util; + sourceTree = ""; + }; + 3A22BF3E29BC8C62004411BE /* Extension */ = { + isa = PBXGroup; + children = ( + 3AA636F229B5F20D006BB5EF /* UITextField+.swift */, + 3AA636C129B07C5F006BB5EF /* UIColor+.swift */, + 3AA636D229B24D72006BB5EF /* UIFont+.swift */, + 3AA636F029B5F1C8006BB5EF /* UIControl+.swift */, + 3AA636F429B61D67006BB5EF /* UIViewController+.swift */, + 3A22BF6629BE05D9004411BE /* Encodable+.swift */, + 3A50F39229BF51F800DC1E29 /* UIButton+.swift */, + 3A396B9229C97E31009B0545 /* UIView+.swift */, + 3A396BA429CA0646009B0545 /* UIWindow+.swift */, + 3A396BD829CF644A009B0545 /* Int+.swift */, + ); + path = Extension; + sourceTree = ""; + }; + 3A22BF3F29BC8D11004411BE /* Application */ = { + isa = PBXGroup; + children = ( + 3AA636AD29AE5055006BB5EF /* AppDelegate.swift */, + 3AA636AF29AE5055006BB5EF /* SceneDelegate.swift */, + ); + path = Application; + sourceTree = ""; + }; + 3A22BF4029BC90D6004411BE /* Network */ = { + isa = PBXGroup; + children = ( + 3A22BF4E29BCCDF7004411BE /* Auth */, + 3A396BB329CB1F0A009B0545 /* Education */, + 3A91C2CD29D0395700028003 /* Call */, + 3A70258E29D5CAE400225F56 /* Dispatch */, + 3A70256929D4291400225F56 /* Address */, + ); + path = Network; + sourceTree = ""; + }; + 3A22BF4129BC90E7004411BE /* Constants */ = { + isa = PBXGroup; + children = ( + 3A22BF4229BC9122004411BE /* URLs.swift */, + 3ADE724E29B9D4AB00EEE19C /* Constraints.swift */, + ); + path = Constants; + sourceTree = ""; + }; + 3A22BF4E29BCCDF7004411BE /* Auth */ = { + isa = PBXGroup; + children = ( + 3A22BF4F29BCCE07004411BE /* AuthEndPoint.swift */, + 3A22BF5129BCD6B8004411BE /* AuthManager.swift */, + ); + path = Auth; + sourceTree = ""; + }; + 3A22BF5329BCE990004411BE /* APIServices */ = { + isa = PBXGroup; + children = ( + 3A22BF5629BCEBA2004411BE /* APIError.swift */, + 3A22BF6029BDFF3B004411BE /* HttpMethod.swift */, + 3A22BF5429BCEAC8004411BE /* NetworkResponse.swift */, + 3A22BF5E29BDFF1A004411BE /* NetworkRequest.swift */, + 3A22BF6429BE01AB004411BE /* EndPoint.swift */, + 3A22BF6829BE0A87004411BE /* Requestable.swift */, + ); + path = APIServices; + sourceTree = ""; + }; + 3A22BF5829BCEFCA004411BE /* Data */ = { + isa = PBXGroup; + children = ( + 3A5C391129C0948600378015 /* Model */, + 3A22BF5929BCF0AA004411BE /* DTO */, + ); + path = Data; + sourceTree = ""; + }; + 3A22BF5929BCF0AA004411BE /* DTO */ = { + isa = PBXGroup; + children = ( + 3A22BF5C29BCF159004411BE /* Auth.swift */, + 3A396BB829CB2686009B0545 /* Education.swift */, + 3A91C2CB29D0392B00028003 /* Call.swift */, + 3A70256E29D4293E00225F56 /* Address.swift */, + 3A70259329D5CC6100225F56 /* Dispatch.swift */, + ); + path = DTO; + sourceTree = ""; + }; + 3A324CD329C60B0400165E2E /* ML */ = { + isa = PBXGroup; + children = ( + 3A324CD429C60B0E00165E2E /* Extension */, + 3A324CD529C60E4700165E2E /* Model */, + ); + path = ML; + sourceTree = ""; + }; + 3A324CD429C60B0E00165E2E /* Extension */ = { + isa = PBXGroup; + children = ( + 3A324CE229C6103800165E2E /* CGSize+TFLite.swift */, + 3A324CE429C6104400165E2E /* CVPixelBuffer+TFLite.swift */, + 3A324CE629C6104D00165E2E /* Data+TFLite.swift */, + ); + path = Extension; + sourceTree = ""; + }; + 3A324CD529C60E4700165E2E /* Model */ = { + isa = PBXGroup; + children = ( + 3A324CD629C60E6400165E2E /* movenet_thunder.tflite */, + 3A324CDA29C60E8F00165E2E /* PoseConfig.swift */, + 3A324CDE29C60E9F00165E2E /* PoseData.swift */, + 3A324CDC29C60E9800165E2E /* MoveNet.swift */, + 3A324CE029C60EAC00165E2E /* PoseEstimator.swift */, + ); + path = Model; + sourceTree = ""; + }; + 3A324CE829C610FF00165E2E /* Camera */ = { + isa = PBXGroup; + children = ( + 3A324CE929C6111200165E2E /* CameraFeedManager.swift */, + ); + path = Camera; + sourceTree = ""; + }; + 3A396BA829CA09DB009B0545 /* Main */ = { + isa = PBXGroup; + children = ( + 3A396BA929CA0A07009B0545 /* TabBarViewController.swift */, + ); + path = Main; + sourceTree = ""; + }; + 3A396BAB29CA0B58009B0545 /* Splash */ = { + isa = PBXGroup; + children = ( + 3A396BA029C9E906009B0545 /* AutoLoginViewController.swift */, + ); + path = Splash; + sourceTree = ""; + }; + 3A396BAE29CAEFF0009B0545 /* Wrapper */ = { + isa = PBXGroup; + children = ( + 3A396BA229C9EDAB009B0545 /* UserDefaultsManager.swift */, + ); + path = Wrapper; + sourceTree = ""; + }; + 3A396BAF29CAF001009B0545 /* Protocol */ = { + isa = PBXGroup; + children = ( + 3A396B9029C8A87D009B0545 /* ViewModelProtocol.swift */, + ); + path = Protocol; + sourceTree = ""; + }; + 3A396BB029CAF0B6009B0545 /* Shared */ = { + isa = PBXGroup; + children = ( + 3A396BB129CAF0DA009B0545 /* DeviceTokenManager.swift */, + 3A70255D29D0869200225F56 /* MapManager.swift */, + ); + path = Shared; + sourceTree = ""; + }; + 3A396BB329CB1F0A009B0545 /* Education */ = { + isa = PBXGroup; + children = ( + 3A396BB429CB1F31009B0545 /* EducationEndPoint.swift */, + 3A396BB629CB1F38009B0545 /* EducationManager.swift */, + ); + path = Education; + sourceTree = ""; + }; + 3A396BC129CCA4F1009B0545 /* Lecture */ = { + isa = PBXGroup; + children = ( + 3A396BC229CCA563009B0545 /* LectureViewController.swift */, + ); + path = Lecture; + sourceTree = ""; + }; + 3A396BC429CF1487009B0545 /* Call */ = { + isa = PBXGroup; + children = ( + 3A396BD429CF4A0E009B0545 /* CallViewModel.swift */, + 3A396BD629CF612F009B0545 /* Main */, + 3A396BD729CF6137009B0545 /* DispatchWait */, + ); + path = Call; + sourceTree = ""; + }; + 3A396BD629CF612F009B0545 /* Main */ = { + isa = PBXGroup; + children = ( + 3A396BC529CF14AB009B0545 /* CallMainViewController.swift */, + 3A396BD229CF3301009B0545 /* TimeCounterView.swift */, + 3A396BCD29CF278E009B0545 /* CurrentLocationNoticeView.swift */, + 3A396BD029CF2BA2009B0545 /* CallCircleView.swift */, + ); + path = Main; + sourceTree = ""; + }; + 3A396BD729CF6137009B0545 /* DispatchWait */ = { + isa = PBXGroup; + children = ( + 3A396BC729CF14D5009B0545 /* DispatchWaitViewController.swift */, + 3A396BC929CF16DF009B0545 /* ApproachNoticeView.swift */, + 3A396BCB29CF204F009B0545 /* EmergencyDescriptionView.swift */, + ); + path = DispatchWait; + sourceTree = ""; + }; + 3A5C391129C0948600378015 /* Model */ = { + isa = PBXGroup; + children = ( + 3A5C391229C0948F00378015 /* Quiz.swift */, + 3A91C2D229D03FCC00028003 /* CallerLocationInfo.swift */, + ); + path = Model; + sourceTree = ""; + }; + 3A70256929D4291400225F56 /* Address */ = { + isa = PBXGroup; + children = ( + 3A70256A29D4292E00225F56 /* AddressEndPoint.swift */, + 3A70256C29D4293800225F56 /* AddressManager.swift */, + ); + path = Address; + sourceTree = ""; + }; + 3A70257C29D47A3200225F56 /* Dispatch */ = { + isa = PBXGroup; + children = ( + 3A70258829D5BD9A00225F56 /* DispatchViewController.swift */, + 3A70258A29D5BFA300225F56 /* DispatchDescriptionView.swift */, + 3A52C73129D5F31D005C6E40 /* DispatchTimerView.swift */, + 3A8C31AA29D62FF1004A4B84 /* ReportViewController.swift */, + ); + path = Dispatch; + sourceTree = ""; + }; + 3A70258029D498B100225F56 /* Component */ = { + isa = PBXGroup; + children = ( + 3A70258129D498C900225F56 /* Sound */, + 3AA636C329B0835D006BB5EF /* Fonts */, + ); + path = Component; + sourceTree = ""; + }; + 3A70258129D498C900225F56 /* Sound */ = { + isa = PBXGroup; + children = ( + 3A8C31B729D6FAAB004A4B84 /* CPR_Sound.mp3 */, + 3A70258429D4C12100225F56 /* CPR_Posture_Sound.mp3 */, + ); + path = Sound; + sourceTree = ""; + }; + 3A70258E29D5CAE400225F56 /* Dispatch */ = { + isa = PBXGroup; + children = ( + 3A70258F29D5CAF300225F56 /* DispatchEndPoint.swift */, + 3A70259129D5CAFE00225F56 /* DispatchManager.swift */, + 3A70259529D5E07700225F56 /* DispatchViewModel.swift */, + ); + path = Dispatch; + sourceTree = ""; + }; + 3A8C319F29D603EA004A4B84 /* Mypage */ = { + isa = PBXGroup; + children = ( + 3A8C31A029D6042B004A4B84 /* MypageViewController.swift */, + 3A8C31A329D6062E004A4B84 /* MypageStatusView.swift */, + 3A8C31AD29D6C9FC004A4B84 /* MypageTableViewCell.swift */, + ); + path = Mypage; + sourceTree = ""; + }; + 3A91C2CD29D0395700028003 /* Call */ = { + isa = PBXGroup; + children = ( + 3A91C2CE29D0396500028003 /* CallEndPoint.swift */, + 3A91C2D029D0396E00028003 /* CallManager.swift */, + ); + path = Call; + sourceTree = ""; + }; + 3AA636A129AE5054006BB5EF = { + isa = PBXGroup; + children = ( + 3AA636AC29AE5055006BB5EF /* CPR2U */, + 3AA636AB29AE5055006BB5EF /* Products */, + 16C861399981B0664D00A6AF /* Pods */, + A7D4AB3E214BA6052EC3FA37 /* Frameworks */, + ); + sourceTree = ""; + }; + 3AA636AB29AE5055006BB5EF /* Products */ = { + isa = PBXGroup; + children = ( + 3AA636AA29AE5055006BB5EF /* CPR2U.app */, + ); + name = Products; + sourceTree = ""; + }; + 3AA636AC29AE5055006BB5EF /* CPR2U */ = { + isa = PBXGroup; + children = ( + 3A396B9629C9C648009B0545 /* CPR2U.entitlements */, + 3A22BF3F29BC8D11004411BE /* Application */, + 3A324CE829C610FF00165E2E /* Camera */, + 3A22BF3D29BC8C5D004411BE /* Util */, + 3A22BF3B29BC8C14004411BE /* Scene */, + 3A22BF5829BCEFCA004411BE /* Data */, + 3A22BF3C29BC8C3F004411BE /* Service */, + 3A324CD329C60B0400165E2E /* ML */, + 3A70258029D498B100225F56 /* Component */, + 3AA636B629AE5057006BB5EF /* Assets.xcassets */, + 3AA636B829AE5057006BB5EF /* LaunchScreen.storyboard */, + 3AA636BB29AE5057006BB5EF /* Info.plist */, + 3A396BAC29CA138A009B0545 /* Private.plist */, + 3A70257929D4731B00225F56 /* GoogleService-Info.plist */, + ); + path = CPR2U; + sourceTree = ""; + }; + 3AA636C329B0835D006BB5EF /* Fonts */ = { + isa = PBXGroup; + children = ( + 3AA636C629B08402006BB5EF /* NotoSans-Bold.ttf */, + 3AA636C729B08402006BB5EF /* NotoSans-Regular.ttf */, + ); + path = Fonts; + sourceTree = ""; + }; + 3AA636DE29B37E04006BB5EF /* Login */ = { + isa = PBXGroup; + children = ( + 3AA636DF29B37E21006BB5EF /* View */, + ); + path = Login; + sourceTree = ""; + }; + 3AA636DF29B37E21006BB5EF /* View */ = { + isa = PBXGroup; + children = ( + 3AA636CC29B0AB17006BB5EF /* PhoneNumberVertificationViewController.swift */, + 3AA636D429B358D0006BB5EF /* SMSCodeVertificationViewController.swift */, + 3AA636D829B36C76006BB5EF /* NicknameVertificationViewController.swift */, + 3AA636D629B35CA3006BB5EF /* SMSCodeInputView.swift */, + 3AA636EC29B5E816006BB5EF /* AuthViewModel.swift */, + ); + path = View; + sourceTree = ""; + }; + 3ADE725029B9D53E00EEE19C /* Education */ = { + isa = PBXGroup; + children = ( + 3A396B8E29C89DA3009B0545 /* EducationViewModel.swift */, + 3ADE725C29BA3BC000EEE19C /* Main */, + 3A396BC129CCA4F1009B0545 /* Lecture */, + 3ADE725D29BA3BCD00EEE19C /* Quiz */, + 3ADE725E29BA3BDB00EEE19C /* Pose */, + ); + path = Education; + sourceTree = ""; + }; + 3ADE725C29BA3BC000EEE19C /* Main */ = { + isa = PBXGroup; + children = ( + 3ADE725129B9D55100EEE19C /* EducationMainViewController.swift */, + 3ADE725329B9D69D00EEE19C /* CertificateStatusView.swift */, + 3ADE725529B9EA3200EEE19C /* EducationProgressView.swift */, + 3ADE725929BA333D00EEE19C /* EducationCollectionViewCell.swift */, + 3A70257229D42DB300225F56 /* AddressSettingView.swift */, + ); + path = Main; + sourceTree = ""; + }; + 3ADE725D29BA3BCD00EEE19C /* Quiz */ = { + isa = PBXGroup; + children = ( + 3ADE725F29BB03A100EEE19C /* EducationQuizViewController.swift */, + 3ADE726529BB04C900EEE19C /* QuizQuestionView.swift */, + 3ADE726129BB047300EEE19C /* QuizChoiceView.swift */, + 3A324CCF29C4BCCF00165E2E /* OXQuizChoiceView.swift */, + 3A324CD129C4BCE000165E2E /* MultiQuizChoiceView.swift */, + 3ADE726729BB21BE00EEE19C /* CustomNoticeView.swift */, + 3A5C390F29C08C8900378015 /* QuizViewModel.swift */, + ); + path = Quiz; + sourceTree = ""; + }; + 3ADE725E29BA3BDB00EEE19C /* Pose */ = { + isa = PBXGroup; + children = ( + 3ADE726B29BB536300EEE19C /* PracticeExplainViewController.swift */, + 3ADE726D29BB59C000EEE19C /* PosePracticeViewController.swift */, + 3ADE726F29BB630F00EEE19C /* PosePracticeResultViewController.swift */, + 3ADE727129BB64D400EEE19C /* EvaluationResultView.swift */, + 3ADE727529BB78BF00EEE19C /* ScoreResultView.swift */, + 3A324CEB29C611AB00165E2E /* CameraOverlayView.swift */, + ); + path = Pose; + sourceTree = ""; + }; + A7D4AB3E214BA6052EC3FA37 /* Frameworks */ = { + isa = PBXGroup; + children = ( + EDF82C0EF3B44D7FED6D1535 /* Pods_CPR2U.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 3AA636A929AE5055006BB5EF /* CPR2U */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3AA636BE29AE5057006BB5EF /* Build configuration list for PBXNativeTarget "CPR2U" */; + buildPhases = ( + 379DD7D0D7CA53D856B6005F /* [CP] Check Pods Manifest.lock */, + 3AA636A629AE5055006BB5EF /* Sources */, + 3AA636A729AE5055006BB5EF /* Frameworks */, + 3AA636A829AE5055006BB5EF /* Resources */, + 59C2C471D36B615BE19C5723 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CPR2U; + packageProductDependencies = ( + 3A5C390D29C0844700378015 /* CombineCocoa */, + 3A396B9A29C9D395009B0545 /* FirebaseMessaging */, + ); + productName = CPR2U; + productReference = 3AA636AA29AE5055006BB5EF /* CPR2U.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 3AA636A229AE5054006BB5EF /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1400; + LastUpgradeCheck = 1400; + TargetAttributes = { + 3AA636A929AE5055006BB5EF = { + CreatedOnToolsVersion = 14.0.1; + }; + }; + }; + buildConfigurationList = 3AA636A529AE5054006BB5EF /* Build configuration list for PBXProject "CPR2U" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 3AA636A129AE5054006BB5EF; + packageReferences = ( + 3A5C390C29C0844700378015 /* XCRemoteSwiftPackageReference "CombineCocoa" */, + 3A396B9929C9D395009B0545 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + ); + productRefGroup = 3AA636AB29AE5055006BB5EF /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 3AA636A929AE5055006BB5EF /* CPR2U */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 3AA636A829AE5055006BB5EF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3A8C31B829D6FAAB004A4B84 /* CPR_Sound.mp3 in Resources */, + 3A70258529D4C12100225F56 /* CPR_Posture_Sound.mp3 in Resources */, + 3AA636C929B08402006BB5EF /* NotoSans-Regular.ttf in Resources */, + 3AA636C829B08402006BB5EF /* NotoSans-Bold.ttf in Resources */, + 3A324CD829C60E6400165E2E /* movenet_thunder.tflite in Resources */, + 3AA636BA29AE5057006BB5EF /* LaunchScreen.storyboard in Resources */, + 3AA636B729AE5057006BB5EF /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 379DD7D0D7CA53D856B6005F /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-CPR2U-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 59C2C471D36B615BE19C5723 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-CPR2U/Pods-CPR2U-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-CPR2U/Pods-CPR2U-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-CPR2U/Pods-CPR2U-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 3AA636A629AE5055006BB5EF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3A70259229D5CAFE00225F56 /* DispatchManager.swift in Sources */, + 3A22BF6529BE01AB004411BE /* EndPoint.swift in Sources */, + 3A8C31A429D6062E004A4B84 /* MypageStatusView.swift in Sources */, + 3ADE725629B9EA3200EEE19C /* EducationProgressView.swift in Sources */, + 3A396BD329CF3301009B0545 /* TimeCounterView.swift in Sources */, + 3ADE726029BB03A100EEE19C /* EducationQuizViewController.swift in Sources */, + 3A22BF5D29BCF159004411BE /* Auth.swift in Sources */, + 3A91C2D329D03FCC00028003 /* CallerLocationInfo.swift in Sources */, + 3A324CDB29C60E8F00165E2E /* PoseConfig.swift in Sources */, + 3A396BCA29CF16DF009B0545 /* ApproachNoticeView.swift in Sources */, + 3A396BA329C9EDAB009B0545 /* UserDefaultsManager.swift in Sources */, + 3A70258929D5BD9A00225F56 /* DispatchViewController.swift in Sources */, + 3A324CDD29C60E9800165E2E /* MoveNet.swift in Sources */, + 3A70255E29D0869200225F56 /* MapManager.swift in Sources */, + 3ADE725229B9D55100EEE19C /* EducationMainViewController.swift in Sources */, + 3A396BC329CCA563009B0545 /* LectureViewController.swift in Sources */, + 3A396BB729CB1F38009B0545 /* EducationManager.swift in Sources */, + 3A70256F29D4293E00225F56 /* Address.swift in Sources */, + 3A396BCC29CF204F009B0545 /* EmergencyDescriptionView.swift in Sources */, + 3A396BD929CF644A009B0545 /* Int+.swift in Sources */, + 3A52C73229D5F31D005C6E40 /* DispatchTimerView.swift in Sources */, + 3ADE726229BB047300EEE19C /* QuizChoiceView.swift in Sources */, + 3AA636F529B61D67006BB5EF /* UIViewController+.swift in Sources */, + 3A396B8F29C89DA3009B0545 /* EducationViewModel.swift in Sources */, + 3AA636F329B5F20D006BB5EF /* UITextField+.swift in Sources */, + 3A5C391029C08C8900378015 /* QuizViewModel.swift in Sources */, + 3ADE726629BB04C900EEE19C /* QuizQuestionView.swift in Sources */, + 3A22BF5529BCEAC8004411BE /* NetworkResponse.swift in Sources */, + 3A70259029D5CAF300225F56 /* DispatchEndPoint.swift in Sources */, + 3A396BB929CB2686009B0545 /* Education.swift in Sources */, + 3AA636D929B36C76006BB5EF /* NicknameVertificationViewController.swift in Sources */, + 3AA636ED29B5E816006BB5EF /* AuthViewModel.swift in Sources */, + 3A324CDF29C60E9F00165E2E /* PoseData.swift in Sources */, + 3ADE726829BB21BE00EEE19C /* CustomNoticeView.swift in Sources */, + 3A396B9329C97E31009B0545 /* UIView+.swift in Sources */, + 3ADE727229BB64D400EEE19C /* EvaluationResultView.swift in Sources */, + 3A324CE729C6104D00165E2E /* Data+TFLite.swift in Sources */, + 3A70256D29D4293800225F56 /* AddressManager.swift in Sources */, + 3A324CEC29C611AB00165E2E /* CameraOverlayView.swift in Sources */, + 3A396BD529CF4A0E009B0545 /* CallViewModel.swift in Sources */, + 3AA636D529B358D0006BB5EF /* SMSCodeVertificationViewController.swift in Sources */, + 3A50F39329BF51F800DC1E29 /* UIButton+.swift in Sources */, + 3A8C31AE29D6C9FC004A4B84 /* MypageTableViewCell.swift in Sources */, + 3A324CD029C4BCCF00165E2E /* OXQuizChoiceView.swift in Sources */, + 3A396BAA29CA0A07009B0545 /* TabBarViewController.swift in Sources */, + 3AA636D729B35CA3006BB5EF /* SMSCodeInputView.swift in Sources */, + 3A22BF6929BE0A87004411BE /* Requestable.swift in Sources */, + 3A70259629D5E07700225F56 /* DispatchViewModel.swift in Sources */, + 3A22BF5729BCEBA2004411BE /* APIError.swift in Sources */, + 3ADE725429B9D69D00EEE19C /* CertificateStatusView.swift in Sources */, + 3ADE726E29BB59C000EEE19C /* PosePracticeViewController.swift in Sources */, + 3A396BB229CAF0DA009B0545 /* DeviceTokenManager.swift in Sources */, + 3A91C2CF29D0396500028003 /* CallEndPoint.swift in Sources */, + 3A396BCE29CF278E009B0545 /* CurrentLocationNoticeView.swift in Sources */, + 3A91C2CC29D0392B00028003 /* Call.swift in Sources */, + 3ADE727029BB630F00EEE19C /* PosePracticeResultViewController.swift in Sources */, + 3A396BA529CA0646009B0545 /* UIWindow+.swift in Sources */, + 3A8C31AB29D62FF1004A4B84 /* ReportViewController.swift in Sources */, + 3A5C391329C0948F00378015 /* Quiz.swift in Sources */, + 3ADE726C29BB536300EEE19C /* PracticeExplainViewController.swift in Sources */, + 3A396BC629CF14AB009B0545 /* CallMainViewController.swift in Sources */, + 3A396BC829CF14D5009B0545 /* DispatchWaitViewController.swift in Sources */, + 3A22BF5229BCD6B8004411BE /* AuthManager.swift in Sources */, + 3AA636CD29B0AB17006BB5EF /* PhoneNumberVertificationViewController.swift in Sources */, + 3ADE724F29B9D4AB00EEE19C /* Constraints.swift in Sources */, + 3A324CE529C6104400165E2E /* CVPixelBuffer+TFLite.swift in Sources */, + 3AA636C229B07C5F006BB5EF /* UIColor+.swift in Sources */, + 3A22BF5029BCCE07004411BE /* AuthEndPoint.swift in Sources */, + 3A396BB529CB1F31009B0545 /* EducationEndPoint.swift in Sources */, + 3A8C31A129D6042B004A4B84 /* MypageViewController.swift in Sources */, + 3AA636AE29AE5055006BB5EF /* AppDelegate.swift in Sources */, + 3A396BA129C9E906009B0545 /* AutoLoginViewController.swift in Sources */, + 3AA636F129B5F1C8006BB5EF /* UIControl+.swift in Sources */, + 3A22BF4329BC9122004411BE /* URLs.swift in Sources */, + 3A324CE329C6103800165E2E /* CGSize+TFLite.swift in Sources */, + 3A91C2D129D0396E00028003 /* CallManager.swift in Sources */, + 3AA636D329B24D72006BB5EF /* UIFont+.swift in Sources */, + 3A70259429D5CC6100225F56 /* Dispatch.swift in Sources */, + 3A22BF6129BDFF3B004411BE /* HttpMethod.swift in Sources */, + 3A70258B29D5BFA300225F56 /* DispatchDescriptionView.swift in Sources */, + 3A70256B29D4292E00225F56 /* AddressEndPoint.swift in Sources */, + 3A324CE129C60EAC00165E2E /* PoseEstimator.swift in Sources */, + 3ADE727629BB78BF00EEE19C /* ScoreResultView.swift in Sources */, + 3AA636B029AE5055006BB5EF /* SceneDelegate.swift in Sources */, + 3A396B9129C8A87D009B0545 /* ViewModelProtocol.swift in Sources */, + 3A22BF5F29BDFF1A004411BE /* NetworkRequest.swift in Sources */, + 3ADE725A29BA333D00EEE19C /* EducationCollectionViewCell.swift in Sources */, + 3A324CD229C4BCE000165E2E /* MultiQuizChoiceView.swift in Sources */, + 3A324CEA29C6111200165E2E /* CameraFeedManager.swift in Sources */, + 3A396BD129CF2BA2009B0545 /* CallCircleView.swift in Sources */, + 3A70257329D42DB300225F56 /* AddressSettingView.swift in Sources */, + 3A22BF6729BE05D9004411BE /* Encodable+.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 3AA636B829AE5057006BB5EF /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 3AA636B929AE5057006BB5EF /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 3AA636BC29AE5057006BB5EF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 3AA636BD29AE5057006BB5EF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 3AA636BF29AE5057006BB5EF /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2B9F1310FB54413A2CCE87FD /* Pods-CPR2U.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = CPR2U/CPR2U.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UAM52RQFW9; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CPR2U/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "Use Camera"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Get Location"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jhHwang.CPRtoU; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 3AA636C029AE5057006BB5EF /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8603206BC53B2CC2028A3EFB /* Pods-CPR2U.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = CPR2U/CPR2U.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = UAM52RQFW9; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CPR2U/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "Use Camera"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Get Location"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jhHwang.CPRtoU; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 3AA636A529AE5054006BB5EF /* Build configuration list for PBXProject "CPR2U" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3AA636BC29AE5057006BB5EF /* Debug */, + 3AA636BD29AE5057006BB5EF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3AA636BE29AE5057006BB5EF /* Build configuration list for PBXNativeTarget "CPR2U" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3AA636BF29AE5057006BB5EF /* Debug */, + 3AA636C029AE5057006BB5EF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 3A396B9929C9D395009B0545 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 9.0.0; + }; + }; + 3A5C390C29C0844700378015 /* XCRemoteSwiftPackageReference "CombineCocoa" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/CombineCommunity/CombineCocoa"; + requirement = { + branch = main; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 3A396B9A29C9D395009B0545 /* FirebaseMessaging */ = { + isa = XCSwiftPackageProductDependency; + package = 3A396B9929C9D395009B0545 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseMessaging; + }; + 3A5C390D29C0844700378015 /* CombineCocoa */ = { + isa = XCSwiftPackageProductDependency; + package = 3A5C390C29C0844700378015 /* XCRemoteSwiftPackageReference "CombineCocoa" */; + productName = CombineCocoa; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 3AA636A229AE5054006BB5EF /* Project object */; +} diff --git a/CPR2U-iOS/CPR2U/CPR2U.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/CPR2U-iOS/CPR2U/CPR2U.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/CPR2U-iOS/CPR2U/CPR2U.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/CPR2U-iOS/CPR2U/CPR2U.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/CPR2U-iOS/CPR2U/CPR2U.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CPR2U-iOS/CPR2U/CPR2U.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..622d26a --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "combinecocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CombineCommunity/CombineCocoa", + "state" : { + "branch" : "main", + "revision" : "7300c75ff9e072aa7fd0fccefcc88f74aae9bf56" + } + } + ], + "version" : 2 +} diff --git a/CPR2U-iOS/CPR2U/CPR2U.xcodeproj/xcshareddata/xcschemes/CPR2U.xcscheme b/CPR2U-iOS/CPR2U/CPR2U.xcodeproj/xcshareddata/xcschemes/CPR2U.xcscheme new file mode 100644 index 0000000..f177b4d --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U.xcodeproj/xcshareddata/xcschemes/CPR2U.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CPR2U-iOS/CPR2U/CPR2U.xcworkspace/contents.xcworkspacedata b/CPR2U-iOS/CPR2U/CPR2U.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..13d4908 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/CPR2U-iOS/CPR2U/CPR2U.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/CPR2U-iOS/CPR2U/CPR2U.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/CPR2U-iOS/CPR2U/CPR2U.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CPR2U-iOS/CPR2U/CPR2U.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4c2a12a --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,122 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-swiftpm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/abseil-cpp-SwiftPM.git", + "state" : { + "revision" : "583de9bd60f66b40e78d08599cc92036c2e7e4e1", + "version" : "0.20220203.2" + } + }, + { + "identity" : "boringssl-swiftpm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/boringssl-SwiftPM.git", + "state" : { + "revision" : "dd3eda2b05a3f459fc3073695ad1b28659066eab", + "version" : "0.9.1" + } + }, + { + "identity" : "combinecocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CombineCommunity/CombineCocoa", + "state" : { + "branch" : "main", + "revision" : "7300c75ff9e072aa7fd0fccefcc88f74aae9bf56" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "7e80c25b51c2ffa238879b07fbfc5baa54bb3050", + "version" : "9.6.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "c1cfde8067668027b23a42c29d11c246152fe046", + "version" : "9.6.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "f6b558e3f801f2cac336b04f615ce111fa9ddaa0", + "version" : "9.2.1" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "0543562f85620b5b7c510c6bcbef75b562a5127b", + "version" : "7.11.0" + } + }, + { + "identity" : "grpc-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-ios.git", + "state" : { + "revision" : "8440b914756e0d26d4f4d054a1c1581daedfc5b6", + "version" : "1.44.3-grpc" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "5ccda3981422a84186387dbb763ba739178b529c", + "version" : "2.3.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b", + "version" : "1.22.2" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "819d0a2173aff699fb8c364b6fb906f7cdb1a692", + "version" : "2.30909.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "ec957ccddbcc710ccc64c9dcbd4c7006fcf8b73a", + "version" : "2.2.0" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "0af9125c4eae12a4973fb66574c53a54962a9e1e", + "version" : "1.21.0" + } + } + ], + "version" : 2 +} diff --git a/CPR2U-iOS/CPR2U/CPR2U.xcworkspace/xcshareddata/xcschemes/CPR2U-Workspace.xcscheme b/CPR2U-iOS/CPR2U/CPR2U.xcworkspace/xcshareddata/xcschemes/CPR2U-Workspace.xcscheme new file mode 100644 index 0000000..f177b4d --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U.xcworkspace/xcshareddata/xcschemes/CPR2U-Workspace.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CPR2U-iOS/CPR2U/CPR2U/Application/AppDelegate.swift b/CPR2U-iOS/CPR2U/CPR2U/Application/AppDelegate.swift new file mode 100644 index 0000000..96cfd77 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Application/AppDelegate.swift @@ -0,0 +1,111 @@ +// +// AppDelegate.swift +// CPR2U +// +// Created by 황정현 on 2023/03/01. +// + +import Firebase +import FirebaseMessaging +import GoogleMaps +import UIKit +import UserNotifications + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + // https://stackoverflow.com/questions/28938660/how-to-lock-orientation-of-one-view-controller-to-portrait-mode-only-in-swift + var orientationLock = UIInterfaceOrientationMask.portrait + + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + return self.orientationLock + } + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + + registerForRemoteNotifications() + + // MARK: FCM Setting + FirebaseApp.configure() + Messaging.messaging().delegate = self + + UNUserNotificationCenter.current().delegate = self + + let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] + UNUserNotificationCenter.current().requestAuthorization( + options: authOptions, + completionHandler: {_, _ in }) + application.registerForRemoteNotifications() + + // MARK: Google Maps Setting + GMSServices.provideAPIKey(URLs.mapsAPIKey) + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let deviceTokenString = deviceToken.map { String(format: "%02x", $0) }.joined() + Messaging.messaging().apnsToken = deviceToken + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + } + + private func registerForRemoteNotifications() { + + // 1. 푸시 center (유저에게 권한 요청 용도) + let center = UNUserNotificationCenter.current() + center.delegate = self // push처리에 대한 delegate - UNUserNotificationCenterDelegate + let options: UNAuthorizationOptions = [.alert, .sound, .badge] + center.requestAuthorization(options: options) { (granted, error) in + + guard granted else { + return + } + + DispatchQueue.main.async { + // 2. APNs에 디바이스 토큰 등록 + UIApplication.shared.registerForRemoteNotifications() + } + } + } +} + +extension AppDelegate: MessagingDelegate { + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + guard let fcmToken = fcmToken else { return } + let dataDict:[String: String] = ["token": fcmToken] + NotificationCenter.default.post(name: Notification.Name("FCMToken"), object: nil, userInfo: dataDict) + DeviceTokenManager.deviceToken = fcmToken + } +} + +extension AppDelegate: UNUserNotificationCenterDelegate { + + // 3. 앱이 foreground상태 일 때, 알림이 온 경우 어떻게 표현할 것인지 처리 + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.alert, .badge, .sound]) + } + + // push를 탭한 경우 처리 + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + let url = response.notification.request.content.userInfo + } +} + diff --git a/CPR2U-iOS/CPR2U/CPR2U/Application/SceneDelegate.swift b/CPR2U-iOS/CPR2U/CPR2U/Application/SceneDelegate.swift new file mode 100644 index 0000000..92a1497 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Application/SceneDelegate.swift @@ -0,0 +1,58 @@ +// +// SceneDelegate.swift +// CPR2U +// +// Created by 황정현 on 2023/03/01. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + let window = UIWindow(windowScene: windowScene) + + self.window = window +// let navVC = UINavigationController(rootViewController: EducationMainViewController()) + let vc = AutoLoginViewController() + window.rootViewController = vc + window.backgroundColor = .white + window.overrideUserInterfaceStyle = .light + window.makeKeyAndVisible() + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } + + +} + diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/AccentColor.colorset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..b06af85 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x46", + "green" : "0x43", + "red" : "0xF7" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/AppIcon.appiconset/1024.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000..dd9ae0e Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/AppIcon.appiconset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..cff1680 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/book.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/book.imageset/Contents.json new file mode 100644 index 0000000..60a8a15 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/book.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "book.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/book.imageset/book.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/book.imageset/book.png new file mode 100644 index 0000000..4d755c8 Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/book.imageset/book.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/certificate.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/certificate.imageset/Contents.json new file mode 100644 index 0000000..526d3a3 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/certificate.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "certificate.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/certificate.imageset/certificate.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/certificate.imageset/certificate.png new file mode 100644 index 0000000..e72abc1 Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/certificate.imageset/certificate.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/certificate_big.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/certificate_big.imageset/Contents.json new file mode 100644 index 0000000..345eb98 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/certificate_big.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "certificate_big.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/certificate_big.imageset/certificate_big.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/certificate_big.imageset/certificate_big.png new file mode 100644 index 0000000..ce9b52c Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/certificate_big.imageset/certificate_big.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/fail_heart.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/fail_heart.imageset/Contents.json new file mode 100644 index 0000000..83386f1 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/fail_heart.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ic_fail_heart.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/fail_heart.imageset/ic_fail_heart.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/fail_heart.imageset/ic_fail_heart.png new file mode 100644 index 0000000..69cc0c2 Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/fail_heart.imageset/ic_fail_heart.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/heart_person.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/heart_person.imageset/Contents.json new file mode 100644 index 0000000..3895f50 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/heart_person.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "heart_person.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/heart_person.imageset/heart_person.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/heart_person.imageset/heart_person.png new file mode 100644 index 0000000..47d2b2b Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/heart_person.imageset/heart_person.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/heart_person_big.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/heart_person_big.imageset/Contents.json new file mode 100644 index 0000000..2fe013e --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/heart_person_big.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "heart_person_big.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/heart_person_big.imageset/heart_person_big.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/heart_person_big.imageset/heart_person_big.png new file mode 100644 index 0000000..ccfbdc2 Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/heart_person_big.imageset/heart_person_big.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/logo.imageset/CPR2ULogo.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/logo.imageset/CPR2ULogo.png new file mode 100644 index 0000000..c6dc81a Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/logo.imageset/CPR2ULogo.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/logo.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/logo.imageset/Contents.json new file mode 100644 index 0000000..a17efaf --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "CPR2ULogo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/map.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/map.imageset/Contents.json new file mode 100644 index 0000000..c78b381 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/map.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "map.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/map.imageset/map.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/map.imageset/map.png new file mode 100644 index 0000000..943ca72 Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/map.imageset/map.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding1.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding1.imageset/Contents.json new file mode 100644 index 0000000..a23768f --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding1.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "_Onboarding1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding1.imageset/_Onboarding1.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding1.imageset/_Onboarding1.png new file mode 100644 index 0000000..a2eff59 Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding1.imageset/_Onboarding1.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding2.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding2.imageset/Contents.json new file mode 100644 index 0000000..50d99f5 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding2.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Onboarding2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding2.imageset/Onboarding2.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding2.imageset/Onboarding2.png new file mode 100644 index 0000000..fa7cb3c Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding2.imageset/Onboarding2.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding3.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding3.imageset/Contents.json new file mode 100644 index 0000000..7c9d81c --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding3.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Onboarding3.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding3.imageset/Onboarding3.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding3.imageset/Onboarding3.png new file mode 100644 index 0000000..f08a4b5 Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding3.imageset/Onboarding3.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding4.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding4.imageset/Contents.json new file mode 100644 index 0000000..948b627 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding4.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Onboarding4.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding4.imageset/Onboarding4.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding4.imageset/Onboarding4.png new file mode 100644 index 0000000..cd2116a Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/onboarding4.imageset/Onboarding4.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/people.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/people.imageset/Contents.json new file mode 100644 index 0000000..0a2e22b --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/people.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "people.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/people.imageset/people.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/people.imageset/people.png new file mode 100644 index 0000000..e0ee4bc Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/people.imageset/people.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/person.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/person.imageset/Contents.json new file mode 100644 index 0000000..ea94c84 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/person.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "person.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/person.imageset/person.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/person.imageset/person.png new file mode 100644 index 0000000..8e1c48a Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/person.imageset/person.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/person_big.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/person_big.imageset/Contents.json new file mode 100644 index 0000000..69b4951 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/person_big.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "person_big.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/person_big.imageset/person_big.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/person_big.imageset/person_big.png new file mode 100644 index 0000000..c699857 Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/person_big.imageset/person_big.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/success_heart.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/success_heart.imageset/Contents.json new file mode 100644 index 0000000..b14ca0d --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/success_heart.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "success_heart.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/success_heart.imageset/success_heart.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/success_heart.imageset/success_heart.png new file mode 100644 index 0000000..5963aee Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/success_heart.imageset/success_heart.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/time.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/time.imageset/Contents.json new file mode 100644 index 0000000..968d147 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/time.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "time.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/time.imageset/time.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/time.imageset/time.png new file mode 100644 index 0000000..db9e2c1 Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/time.imageset/time.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/time_check.imageset/Contents.json b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/time_check.imageset/Contents.json new file mode 100644 index 0000000..9a20698 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/time_check.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "time_check.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/time_check.imageset/time_check.png b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/time_check.imageset/time_check.png new file mode 100644 index 0000000..8491cb2 Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Assets.xcassets/time_check.imageset/time_check.png differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Base.lproj/LaunchScreen.storyboard b/CPR2U-iOS/CPR2U/CPR2U/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CPR2U-iOS/CPR2U/CPR2U/CPR2U.entitlements b/CPR2U-iOS/CPR2U/CPR2U/CPR2U.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/CPR2U.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/CPR2U-iOS/CPR2U/CPR2U/Camera/CameraFeedManager.swift b/CPR2U-iOS/CPR2U/CPR2U/Camera/CameraFeedManager.swift new file mode 100644 index 0000000..33e9b14 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Camera/CameraFeedManager.swift @@ -0,0 +1,109 @@ +// Copyright 2021 The TensorFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ============================================================================= +// +// CameraFeedManager.swift +// CPR2U +// +// Created by 황정현 on 2023/03/19. +// + +import AVFoundation +import Accelerate.vImage +import UIKit + +/// Delegate to receive the frames captured from the device's camera. +protocol CameraFeedManagerDelegate: AnyObject { + + /// Callback method that receives frames from the camera. + /// - Parameters: + /// - cameraFeedManager: The CameraFeedManager instance which calls the delegate. + /// - pixelBuffer: The frame received from the camera. + func cameraFeedManager( + _ cameraFeedManager: CameraFeedManager, didOutput pixelBuffer: CVPixelBuffer) +} + +/// Manage the camera pipeline. +final class CameraFeedManager: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { + + /// Delegate to receive the frames captured by the device's camera. + var delegate: CameraFeedManagerDelegate? + + override init() { + super.init() + configureSession() + } + + /// Start capturing frames from the camera. + func startRunning() { + captureSession.startRunning() + } + + /// Stop capturing frames from the camera. + func stopRunning() { + captureSession.stopRunning() + } + + let captureSession = AVCaptureSession() + + /// Initialize the capture session. + private func configureSession() { + captureSession.sessionPreset = AVCaptureSession.Preset.photo + guard + let frontCamera = AVCaptureDevice.default( + .builtInWideAngleCamera, for: .video, position: .front) + else { + return + } + do { + let input = try AVCaptureDeviceInput(device: frontCamera) + captureSession.addInput(input) + + } catch { + return + } + + let videoOutput = AVCaptureVideoDataOutput() + videoOutput.videoSettings = [ + (kCVPixelBufferPixelFormatTypeKey as String): NSNumber(value: kCVPixelFormatType_32BGRA) + ] + videoOutput.alwaysDiscardsLateVideoFrames = true + let dataOutputQueue = DispatchQueue( + label: "video data queue", + qos: .userInitiated, + attributes: [], + autoreleaseFrequency: .workItem) + if captureSession.canAddOutput(videoOutput) { + captureSession.addOutput(videoOutput) + videoOutput.connection(with: .video)?.videoOrientation = .landscapeRight + videoOutput.connection(with: .video)?.isVideoMirrored = true + captureSession.startRunning() + } + videoOutput.setSampleBufferDelegate(self, queue: dataOutputQueue) + } + + // MARK: Methods of the AVCaptureVideoDataOutputSampleBufferDelegate + func captureOutput( + _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { + return + } + CVPixelBufferLockBaseAddress(pixelBuffer, CVPixelBufferLockFlags.readOnly) + delegate?.cameraFeedManager(self, didOutput: pixelBuffer) + CVPixelBufferUnlockBaseAddress(pixelBuffer, CVPixelBufferLockFlags.readOnly) + } +} + diff --git a/CPR2U-iOS/CPR2U/CPR2U/Component/Fonts/NotoSans-Bold.ttf b/CPR2U-iOS/CPR2U/CPR2U/Component/Fonts/NotoSans-Bold.ttf new file mode 100644 index 0000000..3e68bc2 Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Component/Fonts/NotoSans-Bold.ttf differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Component/Fonts/NotoSans-Regular.ttf b/CPR2U-iOS/CPR2U/CPR2U/Component/Fonts/NotoSans-Regular.ttf new file mode 100644 index 0000000..973bc2e Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Component/Fonts/NotoSans-Regular.ttf differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Component/Sound/CPR_Posture_Sound.mp3 b/CPR2U-iOS/CPR2U/CPR2U/Component/Sound/CPR_Posture_Sound.mp3 new file mode 100644 index 0000000..5eca1cb Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Component/Sound/CPR_Posture_Sound.mp3 differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Component/Sound/CPR_Sound b/CPR2U-iOS/CPR2U/CPR2U/Component/Sound/CPR_Sound new file mode 100644 index 0000000..cfe0233 Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Component/Sound/CPR_Sound differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Component/Sound/CPR_Sound.mp3 b/CPR2U-iOS/CPR2U/CPR2U/Component/Sound/CPR_Sound.mp3 new file mode 100644 index 0000000..cfe0233 Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/Component/Sound/CPR_Sound.mp3 differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Data/DTO/Address.swift b/CPR2U-iOS/CPR2U/CPR2U/Data/DTO/Address.swift new file mode 100644 index 0000000..62c345d --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Data/DTO/Address.swift @@ -0,0 +1,20 @@ +// +// Address.swift +// CPR2U +// +// Created by 황정현 on 2023/03/29. +// + +import Foundation + +struct AddressListResult: Codable { + let sido: String + let gugun_list: [SubAddress] +} + +struct SubAddress: Codable { + let id: Int + let gugun: String +} + +struct SetUserAddressResult: Codable { } diff --git a/CPR2U-iOS/CPR2U/CPR2U/Data/DTO/Auth.swift b/CPR2U-iOS/CPR2U/CPR2U/Data/DTO/Auth.swift new file mode 100644 index 0000000..8ad7ec1 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Data/DTO/Auth.swift @@ -0,0 +1,29 @@ +// +// AuthEndPoint.swift +// CPR2U +// +// Created by 황정현 on 2023/03/12. +// + +import Foundation + +struct SignUpResult: Codable { + let access_token: String + let refresh_token: String +} + +struct SignInResult: Codable { + let access_token: String + let refresh_token: String +} + +struct AutoLoginResult: Codable { + let access_token: String + let refresh_token: String +} + +struct SMSCodeResult: Codable { + let validation_code: String +} + +struct NicknameVerifyResult: Codable {} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Data/DTO/Call.swift b/CPR2U-iOS/CPR2U/CPR2U/Data/DTO/Call.swift new file mode 100644 index 0000000..dbd51de --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Data/DTO/Call.swift @@ -0,0 +1,32 @@ +// +// Call.swift +// CPR2U +// +// Created by 황정현 on 2023/03/26. +// + +import Foundation + +struct CallerListInfo: Codable { + let angel_status: String + let is_patient: Bool + let call_list: [CallerInfo] +} + +struct CallerInfo: Codable { + let latitude: Double + let longitude: Double + let cpr_call_id: Int + let full_address: String + let called_at: String +} + +struct CallResult: Codable { + let call_id: Int +} + +struct CallEndResult:Codable { } + +struct DispatcherCountResult: Codable { + let number_of_angels: Int +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Data/DTO/Dispatch.swift b/CPR2U-iOS/CPR2U/CPR2U/Data/DTO/Dispatch.swift new file mode 100644 index 0000000..8ad88e8 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Data/DTO/Dispatch.swift @@ -0,0 +1,25 @@ +// +// Dispatch.swift +// CPR2U +// +// Created by 황정현 on 2023/03/30. +// + +import Foundation + +struct DispatchInfo: Codable { + let latitude: Double + let longitude: Double + let dispatch_id: Int + let full_address: String + let called_at: String +} + +struct ReportInfo: Codable { + let content: String + let dispatch_id: Int +} + +struct ReportResult: Codable { } + +struct DispatchEndResult: Codable { } diff --git a/CPR2U-iOS/CPR2U/CPR2U/Data/DTO/Education.swift b/CPR2U-iOS/CPR2U/CPR2U/Data/DTO/Education.swift new file mode 100644 index 0000000..bec599c --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Data/DTO/Education.swift @@ -0,0 +1,57 @@ +// +// Education.swift +// CPR2U +// +// Created by 황정현 on 2023/03/22. +// + +import Foundation + +struct QuizResult: Codable { } + +struct LectureProgressResult: Codable { } + +struct PosturePracticeResult: Codable { } + +struct UserInfo: Codable { + let nickname: String + let angel_status: Int + let progress_percent: Double + let is_lecture_completed: Int + let last_lecture_title: String + let is_quiz_completed: Int + let is_posture_completed: Int + let days_left_until_expiration: Int? +} + +struct QuizInfo: Codable { + let index: Int + let question: String + let type: Int + let answer: Int + let reason: String + let answer_content: String + let answer_list: [AnswerInfo] +} + +struct AnswerInfo: Codable { + let id: Int + let content: String +} + +struct LectureProgressInfo: Codable { + let current_step: Int + let lecture_list: [LectureInfo] +} + +struct LectureInfo: Codable { + let id: Int + let step: Int + let title: String + let description: String + let video_url: String +} + +struct PostureLectureInfo: Codable { + let video_url: String +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Data/Model/CallerLocationInfo.swift b/CPR2U-iOS/CPR2U/CPR2U/Data/Model/CallerLocationInfo.swift new file mode 100644 index 0000000..225e8eb --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Data/Model/CallerLocationInfo.swift @@ -0,0 +1,14 @@ +// +// CallerLocationInfo.swift +// CPR2U +// +// Created by 황정현 on 2023/03/26. +// + +import Foundation + +struct CallerLocationInfo: Encodable { + let latitude: Double + let longitude: Double + let full_address: String +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Data/Model/Quiz.swift b/CPR2U-iOS/CPR2U/CPR2U/Data/Model/Quiz.swift new file mode 100644 index 0000000..ae6a225 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Data/Model/Quiz.swift @@ -0,0 +1,22 @@ +// +// Quiz.swift +// CPR2U +// +// Created by 황정현 on 2023/03/14. +// + +import Foundation + +enum QuizType: Int { + case ox = 2 + case multi = 4 +} + +struct Quiz { + let questionType:QuizType + let questionNumber: Int + let question: String + let answerIndex: Int + let answerList: [String] + let answerDescription: String +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Info.plist b/CPR2U-iOS/CPR2U/CPR2U/Info.plist new file mode 100644 index 0000000..730d7f6 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Info.plist @@ -0,0 +1,37 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UIAppFonts + + NotoSans-Bold.ttf + NotoSans-Regular.ttf + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UIBackgroundModes + + remote-notification + + + diff --git a/CPR2U-iOS/CPR2U/CPR2U/ML/Extension/CGSize+TFLite.swift b/CPR2U-iOS/CPR2U/CPR2U/ML/Extension/CGSize+TFLite.swift new file mode 100644 index 0000000..b5964f6 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/ML/Extension/CGSize+TFLite.swift @@ -0,0 +1,52 @@ +// Copyright 2021 The TensorFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ============================================================================= +// +// CGSize+TFLite.swift +// CPR2U +// +// Created by 황정현 on 2023/03/19. +// + +import Accelerate +import Foundation + +extension CGSize { + /// Returns `CGAffineTransform` to resize `self` to fit in destination size, keeping aspect ratio + /// of `self`. `self` image is resized to be inscribe to destination size and located in center of + /// destination. + /// + /// - Parameter toFitIn: destination size to be filled. + /// - Returns: `CGAffineTransform` to transform `self` image to `dest` image. + func transformKeepAspect(toFitIn dest: CGSize) -> CGAffineTransform { + let sourceRatio = self.height / self.width + let destRatio = dest.height / dest.width + + // Calculates ratio `self` to `dest`. + var ratio: CGFloat + var x: CGFloat = 0 + var y: CGFloat = 0 + if sourceRatio > destRatio { + // Source size is taller than destination. Resized to fit in destination height, and find + // horizontal starting point to be centered. + ratio = dest.height / self.height + x = (dest.width - self.width * ratio) / 2 + } else { + ratio = dest.width / self.width + y = (dest.height - self.height * ratio) / 2 + } + return CGAffineTransform(a: ratio, b: 0, c: 0, d: ratio, tx: x, ty: y) + } +} + diff --git a/CPR2U-iOS/CPR2U/CPR2U/ML/Extension/CVPixelBuffer+TFLite.swift b/CPR2U-iOS/CPR2U/CPR2U/ML/Extension/CVPixelBuffer+TFLite.swift new file mode 100644 index 0000000..7052ee7 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/ML/Extension/CVPixelBuffer+TFLite.swift @@ -0,0 +1,233 @@ +// Copyright 2021 The TensorFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ============================================================================= +// +// CVPixelBuffer+TFLite.swift +// CPR2U +// +// Created by 황정현 on 2023/03/19. +// + +import Accelerate +import Foundation + +extension CVPixelBuffer { + + /// Size of the buffer. + var size: CGSize { + return CGSize(width: CVPixelBufferGetWidth(self), height: CVPixelBufferGetHeight(self)) + } + + /// Returns thumbnail by cropping pixel buffer to biggest square and scaling the cropped image + /// to model dimensions. This method only supports 32BGRA or 32ARGB format. It returns nil for + /// other format. + func resized(to size: CGSize) -> CVPixelBuffer? { + let imageWidth = CVPixelBufferGetWidth(self) + let imageHeight = CVPixelBufferGetHeight(self) + let pixelBufferType = CVPixelBufferGetPixelFormatType(self) + guard + pixelBufferType == kCVPixelFormatType_32BGRA || pixelBufferType == kCVPixelFormatType_32ARGB + else { + return nil + } + + let inputImageRowBytes = CVPixelBufferGetBytesPerRow(self) + let imageChannels = 4 + CVPixelBufferLockBaseAddress(self, CVPixelBufferLockFlags(rawValue: 0)) + // Finds the biggest square in the pixel buffer and advances rows based on it. + guard let inputBaseAddress = CVPixelBufferGetBaseAddress(self) else { + return nil + } + + // Gets vImage Buffer from input image + var inputVImageBuffer = vImage_Buffer( + data: inputBaseAddress, height: UInt(imageHeight), width: UInt(imageWidth), + rowBytes: inputImageRowBytes) + + let scaledImageRowBytes = Int(size.width) * imageChannels + guard let scaledImageBytes = malloc(Int(size.height) * scaledImageRowBytes) else { + return nil + } + + // Allocates a vImage buffer for scaled image. + var scaledVImageBuffer = vImage_Buffer( + data: scaledImageBytes, height: UInt(size.height), width: UInt(size.width), + rowBytes: scaledImageRowBytes) + + // Performs the scale operation on input image buffer and stores it in scaled image buffer. + let scaleError = vImageScale_ARGB8888( + &inputVImageBuffer, &scaledVImageBuffer, nil, vImage_Flags(0)) + + CVPixelBufferUnlockBaseAddress(self, CVPixelBufferLockFlags(rawValue: 0)) + + guard scaleError == kvImageNoError else { + return nil + } + + let releaseCallBack: CVPixelBufferReleaseBytesCallback = { mutablePointer, pointer in + if let pointer = pointer { + free(UnsafeMutableRawPointer(mutating: pointer)) + } + } + var scaledPixelBuffer: CVPixelBuffer? + + // Converts the scaled vImage buffer to CVPixelBuffer + let conversionStatus = CVPixelBufferCreateWithBytes( + nil, Int(size.width), Int(size.height), pixelBufferType, scaledImageBytes, + scaledImageRowBytes, releaseCallBack, nil, nil, &scaledPixelBuffer) + + guard conversionStatus == kCVReturnSuccess else { + free(scaledImageBytes) + return nil + } + return scaledPixelBuffer + } + + /// Returns a new `CVPixelBuffer` created by taking the self area and resizing it to the + /// specified target size. Aspect ratios of source image and destination image are expected to be + /// same. + /// + /// - Parameters: + /// - from: Source area of image to be cropped and resized. + /// - to: Size to scale the image to(i.e. image size used while training the model). + /// - Returns: The cropped and resized image of itself. + func cropAndResize(fromRect source: CGRect, toSize size: CGSize) -> CVPixelBuffer? { + let inputImageRowBytes = CVPixelBufferGetBytesPerRow(self) + let imageChannels = 4 + CVPixelBufferLockBaseAddress(self, CVPixelBufferLockFlags(rawValue: 0)) + defer { CVPixelBufferUnlockBaseAddress(self, CVPixelBufferLockFlags(rawValue: 0)) } + + // Finds the address of the upper leftmost pixel of the source area. + guard + let inputBaseAddress = CVPixelBufferGetBaseAddress(self)?.advanced( + by: Int(source.minY) * inputImageRowBytes + Int(source.minX) * imageChannels) + else { + return nil + } + + // Crops given area as vImage Buffer. + var croppedImage = vImage_Buffer( + data: inputBaseAddress, height: UInt(source.height), width: UInt(source.width), + rowBytes: inputImageRowBytes) + + let resultRowBytes = Int(size.width) * imageChannels + guard let resultAddress = malloc(Int(size.height) * resultRowBytes) else { + return nil + } + + // Allocates a vacant vImage buffer for resized image. + var resizedImage = vImage_Buffer( + data: resultAddress, + height: UInt(size.height), width: UInt(size.width), + rowBytes: resultRowBytes + ) + + let error = vImageScale_ARGB8888(&croppedImage, &resizedImage, nil, vImage_Flags(0)) + CVPixelBufferUnlockBaseAddress(self, CVPixelBufferLockFlags(rawValue: 0)) + if error != kvImageNoError { + os_log("Error scaling the image.", type: .error) + free(resultAddress) + return nil + } + + let releaseCallBack: CVPixelBufferReleaseBytesCallback = { mutablePointer, pointer in + if let pointer = pointer { + free(UnsafeMutableRawPointer(mutating: pointer)) + } + } + + var result: CVPixelBuffer? + + // Converts the thumbnail vImage buffer to CVPixelBuffer + let conversionStatus = CVPixelBufferCreateWithBytes( + nil, + Int(size.width), Int(size.height), + CVPixelBufferGetPixelFormatType(self), + resultAddress, + resultRowBytes, + releaseCallBack, + nil, + nil, + &result + ) + + guard conversionStatus == kCVReturnSuccess else { + free(resultAddress) + return nil + } + return result + } + + /// Returns the RGB data representation of the given image buffer with the specified `byteCount`. + /// + /// - Parameters + /// - buffer: The BGRA pixel buffer to convert to RGB data. + /// - isModelQuantized: Whether the model is quantized (i.e. fixed point values rather than + /// floating point values). + /// - Returns: The RGB data representation of the image buffer or `nil` if the buffer could not be + /// converted. + func rgbData(isModelQuantized: Bool, imageMean: Float, imageStd: Float) -> Data? { + CVPixelBufferLockBaseAddress(self, .readOnly) + defer { + CVPixelBufferUnlockBaseAddress(self, .readOnly) + } + guard let sourceData = CVPixelBufferGetBaseAddress(self) else { + return nil + } + + let width = CVPixelBufferGetWidth(self) + let height = CVPixelBufferGetHeight(self) + let sourceBytesPerRow = CVPixelBufferGetBytesPerRow(self) + let destinationChannelCount = 3 + let destinationBytesPerRow = destinationChannelCount * width + + var sourceBuffer = vImage_Buffer( + data: sourceData, + height: vImagePixelCount(height), + width: vImagePixelCount(width), + rowBytes: sourceBytesPerRow) + + guard let destinationData = malloc(height * destinationBytesPerRow) else { + os_log("Error: out of memory.", type: .error) + return nil + } + + defer { + free(destinationData) + } + + var destinationBuffer = vImage_Buffer( + data: destinationData, + height: vImagePixelCount(height), + width: vImagePixelCount(width), + rowBytes: destinationBytesPerRow) + + if CVPixelBufferGetPixelFormatType(self) == kCVPixelFormatType_32BGRA { + vImageConvert_BGRA8888toRGB888(&sourceBuffer, &destinationBuffer, UInt32(kvImageNoFlags)) + } else if CVPixelBufferGetPixelFormatType(self) == kCVPixelFormatType_32ARGB { + vImageConvert_ARGB8888toRGB888(&sourceBuffer, &destinationBuffer, UInt32(kvImageNoFlags)) + } + + let byteData = Data(bytes: destinationBuffer.data, count: destinationBuffer.rowBytes * height) + if isModelQuantized { + return byteData + } + + // Not quantized, convert to floats + let bytes = [UInt8](byteData) + let floats = bytes.map { (Float($0) - imageMean) / imageStd } + return Data(copyingBufferOf: floats) + } +} + diff --git a/CPR2U-iOS/CPR2U/CPR2U/ML/Extension/Data+TFLite.swift b/CPR2U-iOS/CPR2U/CPR2U/ML/Extension/Data+TFLite.swift new file mode 100644 index 0000000..6f06b2b --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/ML/Extension/Data+TFLite.swift @@ -0,0 +1,44 @@ +// Copyright 2021 The TensorFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ============================================================================= +// +// Data+TFLite.swift +// CPR2U +// +// Created by 황정현 on 2023/03/19. +// + +import Foundation +import TensorFlowLite + +// MARK: - Data +extension Data { + /// Creates a new buffer by copying the buffer pointer of the given array. + /// + /// - Warning: The given array's element type `T` must be trivial in that it can be copied bit + /// for bit with no indirection or reference-counting operations; otherwise, reinterpreting + /// data from the resulting buffer has undefined behavior. + /// - Parameter array: An array with elements of type `T`. + init(copyingBufferOf array: [T]) { + self = array.withUnsafeBufferPointer(Data.init) + } + + /// Convert a Data instance to Array representation. + func toArray(type: T.Type) -> [T] where T: AdditiveArithmetic { + var array = [T](repeating: T.zero, count: self.count / MemoryLayout.stride) + _ = array.withUnsafeMutableBytes { self.copyBytes(to: $0) } + return array + } +} + diff --git a/CPR2U-iOS/CPR2U/CPR2U/ML/Model/MoveNet.swift b/CPR2U-iOS/CPR2U/CPR2U/ML/Model/MoveNet.swift new file mode 100644 index 0000000..e40b83a --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/ML/Model/MoveNet.swift @@ -0,0 +1,392 @@ +// Copyright 2021 The TensorFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ============================================================================= +// +// MoveNet.swift +// CPR2U +// +// Created by 황정현 on 2023/03/19. +// + +import Accelerate +import Foundation +import TensorFlowLite + +/// A wrapper to run pose estimation using MoveNet models +final class MoveNet: PoseEstimator { + + // MARK: - Private Properties + // TensorFlow Lite `Interpreter` object for performing inference on a given model. + private var interpreter: Interpreter + + // TensorFlow Lite `Tensor` of model input and output. + private var inputTensor: Tensor + private var outputTensor: Tensor + + // Model config + private var torsoExpansionRatio = 1.9 + private var bodyExpandsionRatio = 1.2 + private let imageMean: Float = 0 + private let imageStd: Float = 1 + private let minCropKeyPointScore: Float32 = 0.2 + private var cropRegion: RectF? + private var isProcessing = false + + // Model files + private let movenetLightningFile = FileInfo(name: "movenet_lightning", ext: "tflite") + private let movenetThunderFile = FileInfo(name: "movenet_thunder", ext: "tflite") + + // MARK: - Initialization + + /// A failable initializer for `MoveNet`. A new instance is created if the model is + /// successfully loaded from the app's main bundle. Default `threadCount` is 4. + init(threadCount: Int, delegate: Delegates, modelType: ModelType) throws { + // Construct the path to the model file. + let fileInfo: FileInfo! = movenetThunderFile + guard + let modelPath = Bundle.main.path( + forResource: movenetThunderFile.name, + ofType: fileInfo.ext) + else { + fatalError("Failed to load the model file with name: \(fileInfo.name).") + } + + // Specify the options for the `Interpreter`. + var options = Interpreter.Options() + options.threadCount = threadCount + + // Specify the delegates for the `Interpreter`. + var delegates: [Delegate]? + delegates = [MetalDelegate()] + + + // Create the `Interpreter`. + interpreter = try Interpreter(modelPath: modelPath, options: options, delegates: delegates) + + // Initialize input and output `Tensor`s. + // Allocate memory for the model's input `Tensor`s. + try interpreter.allocateTensors() + + // Get allocated input and output `Tensor`s. + inputTensor = try interpreter.input(at: 0) + outputTensor = try interpreter.output(at: 0) + } + + /// Runs PoseEstimation model with given image with given source area to destination area. + /// This pose detector can process only one frame at each moment. + /// + /// - Parameters: + /// - on: Input image to run the model. + /// - Returns: Result of the inference and the times consumed in every steps. + func estimateSinglePose(on pixelBuffer: CVPixelBuffer) throws -> (Person, Times) { + // Check if this MoveNet instance is already processing a video frame. + // Return an empty detection result if it's currently busy. + guard !isProcessing else { + throw PoseEstimationError.modelBusy + } + isProcessing = true + defer { + isProcessing = false + } + + // Start times of each process. + let preprocessingStartTime: Date + let inferenceStartTime: Date + let postprocessingStartTime: Date + + // Processing times in seconds. + let preprocessingTime: TimeInterval + let inferenceTime: TimeInterval + let postprocessingTime: TimeInterval + + preprocessingStartTime = Date() + guard let data = preprocess(pixelBuffer) else { + os_log("Preprocessing failed.", type: .error) + throw PoseEstimationError.preprocessingFailed + } + preprocessingTime = Date().timeIntervalSince(preprocessingStartTime) + + // Run inference with the TFLite model + inferenceStartTime = Date() + do { + // Copy the initialized `Data` to the input `Tensor`. + try interpreter.copy(data, toInputAt: 0) + + // Run inference by invoking the `Interpreter`. + try interpreter.invoke() + // Get the output `Tensor` to process the inference results. + outputTensor = try interpreter.output(at: 0) + } catch let error { + os_log( + "Failed to invoke the interpreter with error: %s", type: .error, + error.localizedDescription) + throw PoseEstimationError.inferenceFailed + } + inferenceTime = Date().timeIntervalSince(inferenceStartTime) + + postprocessingStartTime = Date() + guard let result = postprocess(imageSize: pixelBuffer.size, modelOutput: outputTensor) else { + os_log("Postprocessing failed.", type: .error) + throw PoseEstimationError.postProcessingFailed + } + postprocessingTime = Date().timeIntervalSince(postprocessingStartTime) + + let times = Times( + preprocessing: preprocessingTime, + inference: inferenceTime, + postprocessing: postprocessingTime) + return (result, times) + } + + // MARK: - Private functions to run the model + /// Preprocesses given rectangle image to be `Data` of desired size by cropping and resizing it. + /// + /// - Parameters: + /// - of: Input image to crop and resize. + /// - Returns: The cropped and resized image. `nil` if it can not be processed. + private func preprocess(_ pixelBuffer: CVPixelBuffer) -> Data? { + let sourcePixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer) + assert( + sourcePixelFormat == kCVPixelFormatType_32BGRA + || sourcePixelFormat == kCVPixelFormatType_32ARGB) + // Resize `targetSquare` of input image to `modelSize`. + let dimensions = inputTensor.shape.dimensions + let inputWidth = CGFloat(dimensions[1]) + let inputHeight = CGFloat(dimensions[2]) + let imageWidth = pixelBuffer.size.width + let imageHeight = pixelBuffer.size.height + + let cropRegion = self.cropRegion ?? + initialCropRegion(imageWidth: imageWidth, imageHeight: imageHeight) + self.cropRegion = cropRegion + + let rectF = RectF( + left: cropRegion.left * imageWidth, + top: cropRegion.top * imageHeight, + right: cropRegion.right * imageWidth, + bottom: cropRegion.bottom * imageHeight) + + // Detect region + let modelSize = CGSize(width: inputWidth, height: inputHeight) + guard let thumbnail = pixelBuffer.cropAndResize(fromRect: rectF.rect, toSize: modelSize) else { + return nil + } + + // Remove the alpha component from the image buffer to get the initialized `Data`. + guard + let inputData = thumbnail.rgbData( + isModelQuantized: inputTensor.dataType == .uInt8, imageMean: imageMean, imageStd: imageStd) + else { + os_log("Failed to convert the image buffer to RGB data.", type: .error) + return nil + } + + return inputData + } + + /// Postprocesses the output `Tensor` of TFLite model to the `Person` type + /// + /// - Parameters: + /// - imageSize: Size of the input image. + /// - modelOutput: Output tensor from the TFLite model. + /// - Returns: Postprocessed `Person`. `nil` if it can not be processed. + private func postprocess(imageSize: CGSize, modelOutput: Tensor) -> Person? { + let imageWidth = imageSize.width + let imageHeight = imageSize.height + + let cropRegion = self.cropRegion ?? + initialCropRegion(imageWidth: imageWidth, imageHeight: imageHeight) + + let minX: CGFloat = cropRegion.left * imageWidth + let minY: CGFloat = cropRegion.top * imageHeight + + let output = modelOutput.data.toArray(type: Float32.self) + let dimensions = modelOutput.shape.dimensions + let numKeyPoints = dimensions[2] + let inputWidth = CGFloat(inputTensor.shape.dimensions[1]) + let inputHeight = CGFloat(inputTensor.shape.dimensions[2]) + + let widthRatio = (cropRegion.width * imageWidth / inputWidth) + let heightRatio = (cropRegion.height * imageHeight / inputHeight) + + // Translate the coordinates from the model output's [0..1] back to that of + // the input image + var positions: [CGFloat] = [] + var totalScoreSum: Float32 = 0 + var keyPoints: [KeyPoint] = [] + for idx in 0.. RectF + { + let targetKeyPoints = keyPoints.map { keyPoint in + KeyPoint.init(bodyPart: keyPoint.bodyPart, + coordinate: CGPoint(x: keyPoint.coordinate.x, y: keyPoint.coordinate.y), + score: keyPoint.score) + } + if torsoVisible(keyPoints) { + let centerX = + (targetKeyPoints[BodyPart.leftHip.position].coordinate.x + + targetKeyPoints[BodyPart.rightHip.position].coordinate.x) / 2.0 + let centerY = + (targetKeyPoints[BodyPart.leftHip.position].coordinate.y + + targetKeyPoints[BodyPart.rightHip.position].coordinate.y) / 2.0 + + let torsoAndBodyDistances = + determineTorsoAndBodyDistances( + keyPoints: keyPoints, targetKeyPoints: targetKeyPoints, centerX: centerX, centerY: centerY + ) + + let list = [ + torsoAndBodyDistances.maxTorsoXDistance * CGFloat(torsoExpansionRatio), + torsoAndBodyDistances.maxTorsoYDistance * CGFloat(torsoExpansionRatio), + torsoAndBodyDistances.maxBodyXDistance * CGFloat(bodyExpandsionRatio), + torsoAndBodyDistances.maxBodyYDistance * CGFloat(bodyExpandsionRatio), + ] + + var cropLengthHalf = list.max() ?? 0.0 + let tmp: [CGFloat] = [ + centerX, CGFloat(imageWidth) - centerX, centerY, CGFloat(imageHeight) - centerY, + ] + cropLengthHalf = min(cropLengthHalf, tmp.max() ?? 0.0) + let cropCornerY = centerY - cropLengthHalf + let cropCornerX = centerX - cropLengthHalf + if cropLengthHalf > (CGFloat(max(imageWidth, imageHeight)) / 2.0) { + return initialCropRegion(imageWidth: imageWidth, imageHeight: imageHeight) + } else { + let cropLength = cropLengthHalf * 2 + return RectF( + left: max(cropCornerX, 0) / imageWidth, + top: max(cropCornerY, 0) / imageHeight, + right: min((cropCornerX + cropLength) / imageWidth, 1), + bottom: min((cropCornerY + cropLength) / imageHeight, 1)) + } + } else { + return initialCropRegion(imageWidth: imageWidth, imageHeight: imageHeight) + } + } + + /// Defines the default crop region. + /// The function provides the initial crop region (pads the full image from both + /// sides to make it a square image) when the algorithm cannot reliably determine + /// the crop region from the previous frame. + private func initialCropRegion(imageWidth: CGFloat, imageHeight: CGFloat) -> RectF { + var xMin: CGFloat + var yMin: CGFloat + var width: CGFloat + var height: CGFloat + if imageWidth > imageHeight { + height = 1 + width = imageHeight / imageWidth + yMin = 0 + xMin = ((imageWidth - imageHeight) / 2.0) / imageWidth + } else { + width = 1 + height = imageWidth / imageHeight + xMin = 0 + yMin = ((imageHeight - imageWidth) / 2.0) / imageHeight + } + return RectF(left: xMin, top: yMin, right: xMin + width, bottom: yMin + height) + } + + /// Checks whether there are enough torso keypoints. + /// This function checks whether the model is confident at predicting one of the + /// shoulders/hips which is required to determine a good crop region. + private func torsoVisible(_ keyPoints: [KeyPoint]) -> Bool { + return + ((keyPoints[BodyPart.leftHip.position].score > minCropKeyPointScore + || keyPoints[BodyPart.rightHip.position].score > minCropKeyPointScore)) + && ((keyPoints[BodyPart.leftShoulder.position].score > minCropKeyPointScore + || keyPoints[BodyPart.rightShoulder.position].score > minCropKeyPointScore)) + } + + /// Calculates the maximum distance from each keypoints to the center location. + /// The function returns the maximum distances from the two sets of keypoints: + /// full 17 keypoints and 4 torso keypoints. The returned information will be + /// used to determine the crop size. See determineRectF for more detail. + private func determineTorsoAndBodyDistances( + keyPoints: [KeyPoint], targetKeyPoints: [KeyPoint], centerX: CGFloat, centerY: CGFloat + ) -> TorsoAndBodyDistance { + let torsoJoints = [ + BodyPart.leftShoulder.position, + BodyPart.rightShoulder.position, + BodyPart.leftHip.position, + BodyPart.rightHip.position, + ] + + let maxTorsoYRange = torsoJoints.lazy.map { abs(centerY - targetKeyPoints[$0].coordinate.y) } + .max() ?? 0.0 + let maxTorsoXRange = torsoJoints.lazy.map { abs(centerX - targetKeyPoints[$0].coordinate.x) } + .max() ?? 0.0 + + let confidentKeypoints = keyPoints.lazy.filter( {$0.score < self.minCropKeyPointScore} ) + let maxBodyYRange = confidentKeypoints.map({ abs(centerY - $0.coordinate.y) }).max() ?? 0.0 + let maxBodyXRange = confidentKeypoints.map({ abs(centerX - $0.coordinate.x) }).max() ?? 0.0 + + return TorsoAndBodyDistance( + maxTorsoYDistance: maxTorsoYRange, + maxTorsoXDistance: maxTorsoXRange, + maxBodyYDistance: maxBodyYRange, + maxBodyXDistance: maxBodyXRange) + } +} + +/// Size of a detected person. +struct TorsoAndBodyDistance { + var maxTorsoYDistance: CGFloat + var maxTorsoXDistance: CGFloat + var maxBodyYDistance: CGFloat + var maxBodyXDistance: CGFloat +} + +/// A rectangle with calculated properties for convenient access. +struct RectF { + var left: CGFloat + var top: CGFloat + var right: CGFloat + var bottom: CGFloat + var width: CGFloat { right - left } + var height: CGFloat { bottom - top } + + var rect: CGRect { .init(x: left, y: top, width: width, height: height) } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/ML/Model/PoseConfig.swift b/CPR2U-iOS/CPR2U/CPR2U/ML/Model/PoseConfig.swift new file mode 100644 index 0000000..d95ca32 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/ML/Model/PoseConfig.swift @@ -0,0 +1,41 @@ +// Copyright 2021 The TensorFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ============================================================================= +// +// PoseConfig.swift +// CPR2U +// +// Created by 황정현 on 2023/03/19. +// + +import Foundation + +// MARK: Run configurations + +/// TFLite Delegate used to run the model. +enum Delegates: String, CaseIterable { + case gpu = "GPU" +} + +/// Information about a TFLite model file. +struct FileInfo { + var name: String + var ext: String +} + +/// Type of the pose estimation model to be used. +enum ModelType: String, CaseIterable { + case movenetThunder = "Thunder" // Movenet thunder +} + diff --git a/CPR2U-iOS/CPR2U/CPR2U/ML/Model/PoseData.swift b/CPR2U-iOS/CPR2U/CPR2U/ML/Model/PoseData.swift new file mode 100644 index 0000000..9010df2 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/ML/Model/PoseData.swift @@ -0,0 +1,71 @@ + +// Copyright 2021 The TensorFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ============================================================================= +// +// PoseData.swift +// CPR2U +// +// Created by 황정현 on 2023/03/19. +// + +import UIKit + +// MARK: Detection result +/// Time required to run pose estimation on one frame. +struct Times { + var preprocessing: TimeInterval + var inference: TimeInterval + var postprocessing: TimeInterval + var total: TimeInterval { preprocessing + inference + postprocessing } +} + +/// An enum describing a body part (e.g. nose, left eye etc.). +enum BodyPart: String, CaseIterable { + case nose = "nose" + case leftEye = "left eye" + case rightEye = "right eye" + case leftEar = "left ear" + case rightEar = "right ear" + case leftShoulder = "left shoulder" + case rightShoulder = "right shoulder" + case leftElbow = "left elbow" + case rightElbow = "right elbow" + case leftWrist = "left wrist" + case rightWrist = "right wrist" + case leftHip = "left hip" + case rightHip = "right hip" + case leftKnee = "left knee" + case rightKnee = "right knee" + case leftAnkle = "left ankle" + case rightAnkle = "right ankle" + + /// Get the index of the body part in the array returned by pose estimation models. + var position: Int { + return BodyPart.allCases.firstIndex(of: self) ?? 0 + } +} + +/// A body keypoint (e.g. nose) 's detection result. +struct KeyPoint { + var bodyPart: BodyPart = .nose + var coordinate: CGPoint = .zero + var score: Float32 = 0.0 +} + +/// A person detected by a pose estimation model. +struct Person { + var keyPoints: [KeyPoint] + var score: Float32 +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/ML/Model/PoseEstimator.swift b/CPR2U-iOS/CPR2U/CPR2U/ML/Model/PoseEstimator.swift new file mode 100644 index 0000000..064964e --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/ML/Model/PoseEstimator.swift @@ -0,0 +1,35 @@ +// Copyright 2021 The TensorFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ============================================================================= +// +// PoseEstimator.swift +// CPR2U +// +// Created by 황정현 on 2023/03/19. +// + +import UIKit + +/// Protocol to run a pose estimator. +protocol PoseEstimator { + func estimateSinglePose(on pixelbuffer: CVPixelBuffer) throws -> (Person, Times) +} + +// MARK: - Custom Errors +enum PoseEstimationError: Error { + case modelBusy + case preprocessingFailed + case inferenceFailed + case postProcessingFailed +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/ML/Model/movenet_thunder.tflite b/CPR2U-iOS/CPR2U/CPR2U/ML/Model/movenet_thunder.tflite new file mode 100644 index 0000000..1582dc7 Binary files /dev/null and b/CPR2U-iOS/CPR2U/CPR2U/ML/Model/movenet_thunder.tflite differ diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/CallViewModel.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/CallViewModel.swift new file mode 100644 index 0000000..0be158d --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/CallViewModel.swift @@ -0,0 +1,111 @@ +// +// CallViewModel.swift +// CPR2U +// +// Created by 황정현 on 2023/03/26. +// + +import Combine +import Foundation +import GoogleMaps + +final class CallViewModel: OutputOnlyViewModelType { + private var callManager: CallManager + + private var mapManager: MapManager + private var currentLocation: CLLocationCoordinate2D? + + private var currentLocationAddress = CurrentValueSubject("Unable") + var callerList: CurrentValueSubject? + + private var callId: Int? + private let iscalled = CurrentValueSubject(false) + + var timer: Timer.TimerPublisher? + + init() { + callManager = CallManager(service: APIManager()) + mapManager = MapManager() + Task { + try await receiveCallerList() + } + setLocation() + } + + struct Output { + let isCalled: CurrentValueSubject + let currentLocationAddress: CurrentValueSubject? + let callerList: CurrentValueSubject? + } + + func transform() -> Output { + return Output(isCalled: iscalled, currentLocationAddress: currentLocationAddress, callerList: callerList) + } + + func isCallSucceed() { + iscalled.send(true) + } + + func cancelTimer() { + timer?.connect().cancel() + } + + func setLocation() { + currentLocation = mapManager.setLocation() + } + + func getLocation() -> CLLocationCoordinate2D { + setLocation() + guard let currentLocation = currentLocation else { return CLLocationCoordinate2D(latitude: 15, longitude: 15) } + return currentLocation + } + + func setLocationAddress(str: String) { + currentLocationAddress.send(str) + } + + func receiveCallerList() async throws -> CallerListInfo? { + let result = Task { () -> CallerListInfo? in + let callResult = try await callManager.getCallerList() + + guard let list = callResult.data else { return nil } + callerList = CurrentValueSubject(list) + print(callerList) + return list + } + return try await result.value + } + + func callDispatcher() async throws { + Task { + let address = self.currentLocationAddress.value + let callerLocationInfo = CallerLocationInfo(latitude: getLocation().latitude, longitude: getLocation().longitude, full_address: address ) + let callResult = try await callManager.callDispatcher(callerLocationInfo: callerLocationInfo) + guard let data = callResult.data else { return } + updateCallId(callId: data.call_id) + } + } + + func situationEnd() async throws { + guard let callId = callId else { return } + + Task { + try await callManager.situationEnd(callId: callId) + } + } + + func countDispatcher() async throws -> Int? { + guard let callId = callId else { return nil } + + let result = Task { () -> Int? in + let callResult = try await callManager.countDispatcher(callId: callId) + return callResult.data?.number_of_angels + } + + return try await result.value + } + + private func updateCallId(callId: Int) { + self.callId = callId + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/DispatchWait/ApproachNoticeView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/DispatchWait/ApproachNoticeView.swift new file mode 100644 index 0000000..36918ff --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/DispatchWait/ApproachNoticeView.swift @@ -0,0 +1,211 @@ +// +// ApproachNoticeView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/25. +// + +import Combine +import UIKit + +final class ApproachNoticeView: UIView { + + private let peopleImageView: UIImageView = { + let view = UIImageView() + view.image = UIImage(named: "people.png") + return view + }() + + private lazy var peopleCountLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 48) + label.textColor = .mainRed + label.textAlignment = .center + label.text = "0" + return label + }() + + private let approachLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 20) + label.textColor = .black + label.textAlignment = .left + label.text = "Approaching" + return label + }() + + private let timeImageView: UIImageView = { + let view = UIImageView() + view.image = UIImage(named: "time.png") + return view + }() + + private lazy var timeLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 48) + label.textColor = .mainRed + label.textAlignment = .right + label.text = "00:00" + return label + }() + + private let situationEndButton: UIButton = { + let button = UIButton() + button.titleLabel?.font = UIFont(weight: .bold, size: 18) + button.setTitleColor(.white, for: .normal) + button.backgroundColor = .mainRed + button.layer.cornerRadius = 27.5 + button.setTitle("SITUATION ENDED", for: .normal) + return button + }() + + private var viewModel: CallViewModel + private var cancellables = Set() + + required init(viewModel: CallViewModel) { + self.viewModel = viewModel + super.init(frame: CGRect.zero) + + setUpConstraints() + setUpStyle() + bind(viewModel: viewModel) + setTimer() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setUpConstraints() { + let make = Constraints.shared + + let approachStackView = UIStackView() + approachStackView.axis = NSLayoutConstraint.Axis.horizontal + approachStackView.distribution = UIStackView.Distribution.equalSpacing + approachStackView.alignment = UIStackView.Alignment.center + approachStackView.spacing = 8 + + [ + peopleImageView, + peopleCountLabel, + approachLabel + ].forEach({ + approachStackView.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + $0.centerYAnchor.constraint(equalTo: approachStackView.centerYAnchor).isActive = true + }) + + NSLayoutConstraint.activate([ + peopleImageView.leadingAnchor.constraint(equalTo: approachStackView.leadingAnchor), + peopleImageView.widthAnchor.constraint(equalToConstant: 50), + peopleImageView.heightAnchor.constraint(equalToConstant: 50) + ]) + + NSLayoutConstraint.activate([ + peopleCountLabel.leadingAnchor.constraint(equalTo: peopleImageView.trailingAnchor), + peopleCountLabel.trailingAnchor.constraint(equalTo: approachLabel.leadingAnchor), + peopleCountLabel.heightAnchor.constraint(equalToConstant: 50) + ]) + + NSLayoutConstraint.activate([ + approachLabel.trailingAnchor.constraint(equalTo: approachStackView.trailingAnchor), + approachLabel.widthAnchor.constraint(equalToConstant: 128), + approachLabel.heightAnchor.constraint(equalToConstant: 50) + ]) + + let timeStackView = UIStackView() + timeStackView.axis = NSLayoutConstraint.Axis.horizontal + timeStackView.distribution = UIStackView.Distribution.equalSpacing + timeStackView.alignment = UIStackView.Alignment.center + timeStackView.spacing = 8 + + [ + timeImageView, + timeLabel + ].forEach({ + timeStackView.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + $0.centerYAnchor.constraint(equalTo: timeStackView.centerYAnchor).isActive = true + }) + + NSLayoutConstraint.activate([ + timeImageView.leadingAnchor.constraint(equalTo: timeStackView.leadingAnchor), + timeImageView.widthAnchor.constraint(equalToConstant: 50), + timeImageView.heightAnchor.constraint(equalToConstant: 50) + ]) + + NSLayoutConstraint.activate([ + timeLabel.trailingAnchor.constraint(equalTo: timeStackView.trailingAnchor), + timeLabel.widthAnchor.constraint(equalToConstant: 182), + timeLabel.heightAnchor.constraint(equalToConstant: 50) + ]) + + [ + approachStackView, + timeStackView, + situationEndButton + ].forEach({ + self.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + approachStackView.topAnchor.constraint(equalTo: self.topAnchor, constant: 36), + approachStackView.centerXAnchor.constraint(equalTo: self.centerXAnchor), + approachStackView.widthAnchor.constraint(equalToConstant: 222), + approachStackView.heightAnchor.constraint(equalToConstant: 50) + ]) + + NSLayoutConstraint.activate([ + timeStackView.centerXAnchor.constraint(equalTo: self.centerXAnchor), + timeStackView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + timeStackView.widthAnchor.constraint(equalToConstant: 182), + timeStackView.heightAnchor.constraint(equalToConstant: 50) + ]) + + NSLayoutConstraint.activate([ + situationEndButton.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -make.space8), + situationEndButton.centerXAnchor.constraint(equalTo: self.centerXAnchor), + situationEndButton.widthAnchor.constraint(equalToConstant: 340), + situationEndButton.heightAnchor.constraint(equalToConstant: 55) + ]) + } + + private func setUpStyle() { + backgroundColor = .white + self.layer.cornerRadius = 24 + } + + + private func bind(viewModel: CallViewModel) { + situationEndButton.tapPublisher.sink { + Task { + try await viewModel.situationEnd() + self.parentViewController().dismiss(animated: true) + } + }.store(in: &cancellables) + } + + private func setTimer() { + viewModel.timer = Timer.publish(every: 1,tolerance: 0.9, on: .main, in: .default) + viewModel.timer? + .autoconnect() + .scan(0) { counter, _ in counter + 1 } + .sink { [self] counter in + + if counter % 15 == 0 { + Task { + let dispatcherNumber = try await viewModel.countDispatcher() + peopleCountLabel.text = "\(dispatcherNumber ?? 0)" + } + + } + if counter == 301 { + viewModel.timer?.connect().cancel() + } else { + timeLabel.text = counter.numberAsTime() + } + }.store(in: &cancellables) + } + +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/DispatchWait/DispatchWaitViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/DispatchWait/DispatchWaitViewController.swift new file mode 100644 index 0000000..a924dc4 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/DispatchWait/DispatchWaitViewController.swift @@ -0,0 +1,100 @@ +// +// DispatchWaitViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/25. +// + +import AVFoundation +import Combine +import UIKit + +final class DispatchWaitViewController: UIViewController { + + private let mainLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 64) + label.textAlignment = .center + label.textColor = .white + label.text = "Call" + return label + }() + + private lazy var approachNoticeView: ApproachNoticeView = { + let view = ApproachNoticeView(viewModel: viewModel) + return view + }() + + private let emergencyDescriptionView = EmergencyDescriptionView() + + private let viewModel: CallViewModel + private var audioPlayer: AVAudioPlayer! + + init(viewModel: CallViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setUpConstraints() + setUpStyle() + playSound() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(true) + viewModel.cancelTimer() + audioPlayer.stop() + } + + private func setUpConstraints() { + [ + mainLabel, + approachNoticeView, + emergencyDescriptionView + ].forEach({ + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + $0.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true + }) + + NSLayoutConstraint.activate([ + approachNoticeView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + approachNoticeView.widthAnchor.constraint(equalToConstant: 358), + approachNoticeView.heightAnchor.constraint(equalToConstant: 245) + ]) + + NSLayoutConstraint.activate([ + mainLabel.bottomAnchor.constraint(equalTo: approachNoticeView.topAnchor, constant: -36), + mainLabel.widthAnchor.constraint(equalToConstant: 200), + mainLabel.heightAnchor.constraint(equalToConstant: 80) + ]) + + NSLayoutConstraint.activate([ + emergencyDescriptionView.topAnchor.constraint(equalTo: approachNoticeView.bottomAnchor, constant: 68), + emergencyDescriptionView.widthAnchor.constraint(equalToConstant: 358), + emergencyDescriptionView.heightAnchor.constraint(equalToConstant: 198) + ]) + } + + private func setUpStyle() { + view.backgroundColor = .mainRed + } + + private func playSound() { + guard let url = Bundle.main.url(forResource: "CPR_Sound", withExtension: "mp3") else { return } + do { + audioPlayer = try AVAudioPlayer(contentsOf: url) + audioPlayer.numberOfLoops = -1 + } catch (let error) { + print(error) + } + audioPlayer?.play() + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/DispatchWait/EmergencyDescriptionView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/DispatchWait/EmergencyDescriptionView.swift new file mode 100644 index 0000000..eacea91 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/DispatchWait/EmergencyDescriptionView.swift @@ -0,0 +1,72 @@ +// +// EmergencyNoticeView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/25. +// + +import UIKit + +final class EmergencyDescriptionView: UIView { + + private let mainLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 20) + label.textAlignment = .left + label.textColor = .mainBlack + label.text = "Call 911" + return label + }() + + private let descriptonLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 14) + label.textAlignment = .left + label.numberOfLines = 4 + label.textColor = .mainBlack + label.text = "Calling 911 is the first priority. Ask the people around you to report or perform CPR after reporting. If the report is false, you will be restricted from using the app." + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + setUpConstraints() + setUpStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setUpConstraints() { + let make = Constraints.shared + + [ + mainLabel, + descriptonLabel + ].forEach({ + self.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + mainLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: make.space24), + mainLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: make.space24), + mainLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -make.space24), + mainLabel.heightAnchor.constraint(equalToConstant: 32) + ]) + + NSLayoutConstraint.activate([ + descriptonLabel.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -34), + descriptonLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: make.space24), + descriptonLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -make.space24), + descriptonLabel.heightAnchor.constraint(equalToConstant: 96) + ]) + } + + private func setUpStyle() { + backgroundColor = .white + self.layer.cornerRadius = 8 + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/Main/CallCircleView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/Main/CallCircleView.swift new file mode 100644 index 0000000..439af5e --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/Main/CallCircleView.swift @@ -0,0 +1,85 @@ +// +// CallCircleView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/25. +// https://cemkazim.medium.com/how-to-create-animated-circular-progress-bar-in-swift-f86c4d22f74b + +import UIKit + +final class CallCircleView: UIView { + + private let bellImageView: UIImageView = { + let view = UIImageView() + let config = UIImage.SymbolConfiguration(pointSize: 40, weight: .light, scale: .medium) + guard let img = UIImage(systemName: "bell", withConfiguration: config)?.withTintColor(.white).withRenderingMode(.alwaysOriginal) else { return UIImageView() } + view.image = img + return view + }() + + private var circleLayer = CAShapeLayer() + private var progressLayer = CAShapeLayer() + private var startPoint = CGFloat(-Double.pi / 2) + private var endPoint = CGFloat(3 * Double.pi / 2) + + override init(frame: CGRect) { + super.init(frame: frame) + createCircularPath() + setUpConstraints() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + private func setUpConstraints() { + self.addSubview(bellImageView) + bellImageView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + bellImageView.centerXAnchor.constraint(equalTo: self.centerXAnchor), + bellImageView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + bellImageView.widthAnchor.constraint(equalToConstant: 40), + bellImageView.heightAnchor.constraint(equalToConstant: 40) + ]) + } + + private func createCircularPath() { + [ + circleLayer, + progressLayer + ].forEach({ + layer.addSublayer($0) + }) + + let circularPath = UIBezierPath(arcCenter: CGPoint(x: 40, y: 40), radius: 40, startAngle: startPoint, endAngle: endPoint, clockwise: true) + circleLayer.path = circularPath.cgPath + circleLayer.fillColor = UIColor.mainRed.cgColor + circleLayer.lineCap = .round + circleLayer.lineWidth = 12.0 + circleLayer.strokeEnd = 1.0 + circleLayer.strokeColor = UIColor.white.cgColor + + progressLayer.path = circularPath.cgPath + progressLayer.fillColor = UIColor.clear.cgColor + progressLayer.lineCap = .round + progressLayer.lineWidth = 10.0 + progressLayer.strokeEnd = 0 + progressLayer.strokeColor = UIColor.mainRed.cgColor + + } + + func progressAnimation() { + let duration = TimeInterval(3.0) + let circularProgressAnimation = CABasicAnimation(keyPath: "strokeEnd") + circularProgressAnimation.duration = duration + circularProgressAnimation.toValue = 1.0 + circularProgressAnimation.fillMode = .forwards + circularProgressAnimation.isRemovedOnCompletion = false + progressLayer.add(circularProgressAnimation, forKey: "progressAnim") + } + + func cancelProgressAnimation() { + progressLayer.removeAnimation(forKey: "progressAnim") + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/Main/CallMainViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/Main/CallMainViewController.swift new file mode 100644 index 0000000..09e8d48 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/Main/CallMainViewController.swift @@ -0,0 +1,210 @@ +// +// CallMainViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/25. +// + +import Combine +import GoogleMaps +import UIKit + +final class CallMainViewController: UIViewController { + + private lazy var mapView: GMSMapView = { + let view = GMSMapView(frame: self.view.frame) + return view + }() + + private lazy var userLocationMarker: GMSMarker = { + let marker = GMSMarker() + return marker + }() + private var callerLocationMarkers: [GMSMarker] = [] + + private lazy var timeCounterView = { + let view = TimeCounterView(viewModel: viewModel) + return view + }() + private let currentLocationNoticeView = CurrentLocationNoticeView() + private let callButton = CallCircleView() + + private let viewModel: CallViewModel + private var cancellables = Set() + + init(viewModel: CallViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + bind(viewModel: viewModel) + setUpConstraints() + setUpUserLocation() + setUpCallerLocation() + setUpStyle() + setUpDelegate() + setUpAction() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + setUpUserLocation() + setUpCallerLocation() + } + + private func setUpConstraints() { + let safeArea = view.safeAreaLayoutGuide + let make = Constraints.shared + + [ + mapView, + timeCounterView, + currentLocationNoticeView, + callButton + ].forEach({ + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + timeCounterView.topAnchor.constraint(equalTo: view.topAnchor), + timeCounterView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor), + timeCounterView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor), + timeCounterView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor) + ]) + + NSLayoutConstraint.activate([ + currentLocationNoticeView.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: make.space16), + currentLocationNoticeView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: make.space16), + currentLocationNoticeView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -make.space16), + currentLocationNoticeView.heightAnchor.constraint(equalToConstant: 50) + ]) + + NSLayoutConstraint.activate([ + callButton.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -make.space16), + callButton.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor), + callButton.widthAnchor.constraint(equalToConstant: 80), + callButton.heightAnchor.constraint(equalToConstant: 80) + ]) + + } + + private func setUpStyle() { + view.backgroundColor = .lightGray + } + + private func setUpDelegate() { + mapView.delegate = self + } + + private func setUpAction() { + let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(didPressCallButton)) + recognizer.minimumPressDuration = 0.0 + callButton.addGestureRecognizer(recognizer) + + } + + private func setUpUserLocation() { + // MARK: Location + let location = viewModel.getLocation() + let camera = GMSCameraPosition.camera(withLatitude: location.latitude, longitude: location.longitude, zoom: 15.0) + mapView.camera = camera + + // MARK: Location Text + Task { + let temp = try await GMSGeocoder().reverseGeocodeCoordinate(location) + guard let refinedAddress = temp.results()?[0].lines?.joined() else { return } + let idx = refinedAddress.firstIndex(of: " ")! + let index = refinedAddress.distance(from: refinedAddress.startIndex, to: idx) + let startIndex = refinedAddress.index(refinedAddress.startIndex, offsetBy: index) + var address = "\(refinedAddress[startIndex...])" + address.remove(at: address.startIndex) + + viewModel.setLocationAddress(str: address) + } + + // MARK: User Location Marker + mapView.isMyLocationEnabled = true + } + + private func setUpCallerLocation() { + + Task { + if !callerLocationMarkers.isEmpty { + callerLocationMarkers.forEach({ $0.map = nil }) + callerLocationMarkers = [] + } + + guard let callerList = try await self.viewModel.receiveCallerList() else { return } + + for caller in callerList.call_list { + let coor = CLLocationCoordinate2D(latitude: caller.latitude, longitude: caller.longitude) + print(coor) + let marker = GMSMarker() + marker.title = String(caller.cpr_call_id) + marker.position = CLLocationCoordinate2DMake(coor.latitude, coor.longitude) + marker.map = mapView + callerLocationMarkers.append(marker) + } + } + } + + private func bind(viewModel: CallViewModel) { + let output = viewModel.transform() + + output.isCalled.sink { isCalled in + if isCalled { + Task { + try await viewModel.callDispatcher() + let vc = DispatchWaitViewController(viewModel: viewModel) + vc.modalPresentationStyle = .fullScreen + self.present(vc, animated: true) + self.callButton.cancelProgressAnimation() + self.timeCounterView.cancelTimeCount() + } + } + }.store(in: &cancellables) + + output.currentLocationAddress?.sink { address in + self.currentLocationNoticeView.setUpLocationLabelText(as: address) + }.store(in: &cancellables) + + output.callerList?.sink { list in + print(list) + if list.angel_status == "ACQUIRED" && !list.is_patient { + print(list.call_list) + } + + }.store(in: &cancellables) + } + + @objc func didPressCallButton(_ sender: UILongPressGestureRecognizer) { + let state = sender.state + if state == .began { + callButton.progressAnimation() + timeCounterView.timeCountAnimation() + } else if state == .ended { + callButton.cancelProgressAnimation() + timeCounterView.cancelTimeCount() + } + } +} + +extension CallMainViewController: GMSMapViewDelegate { + func mapView(_ mapView: GMSMapView, didTap marker: GMSMarker) -> Bool + { + guard let callId = Int(marker.title ?? "0") else { return false } + guard let target = viewModel.callerList?.value.call_list.filter{$0.cpr_call_id == callId}.first else { return false } + + let callerInfo = CallerCompactInfo(callerId: target.cpr_call_id, latitude: target.latitude, longitude: target.longitude, callerAddress: target.full_address) + let navigationController = UINavigationController(rootViewController: DispatchViewController(userLocation: viewModel.getLocation(), callerInfo: callerInfo)) + present(navigationController, animated: true, completion: nil) + return true + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/Main/CurrentLocationNoticeView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/Main/CurrentLocationNoticeView.swift new file mode 100644 index 0000000..49ccec8 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/Main/CurrentLocationNoticeView.swift @@ -0,0 +1,76 @@ +// +// CurrentLocationNoticeView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/25. +// + +import UIKit + +class CurrentLocationNoticeView: UIView { + + private let pinImageView: UIImageView = { + let view = UIImageView() + let config = UIImage.SymbolConfiguration(pointSize: 28, weight: .light, scale: .medium) + guard let img = UIImage(systemName: "mappin.circle", withConfiguration: config)?.withTintColor(.mainRed).withRenderingMode(.alwaysOriginal) else { return UIImageView() } + view.image = img + return view + }() + + private lazy var locationLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 16) + label.textAlignment = .left + label.textColor = .black + label.minimumScaleFactor = 0.5 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + setUpConstraints() + setUpStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setUpConstraints() { + let make = Constraints.shared + + [ + pinImageView, + locationLabel + ].forEach({ + self.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + $0.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true + }) + + NSLayoutConstraint.activate([ + pinImageView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: make.space8), + pinImageView.widthAnchor.constraint(equalToConstant: 28), + pinImageView.heightAnchor.constraint(equalToConstant: 28) + ]) + + NSLayoutConstraint.activate([ + locationLabel.leadingAnchor.constraint(equalTo: pinImageView.trailingAnchor, constant: make.space8), + locationLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -make.space8), + locationLabel.heightAnchor.constraint(equalToConstant: 28) + ]) + } + + private func setUpStyle() { + backgroundColor = .white + self.layer.borderColor = UIColor.mainRed.cgColor + self.layer.borderWidth = 2 + self.layer.cornerRadius = 20 + } + + func setUpLocationLabelText(as str: String) { + locationLabel.text = str + } + +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/Main/TimeCounterView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/Main/TimeCounterView.swift new file mode 100644 index 0000000..157e3b7 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Call/Main/TimeCounterView.swift @@ -0,0 +1,86 @@ +// +// TimeCounterView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/25. +// + +import UIKit +import Combine + +final class TimeCounterView: UIView { + private lazy var timeLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 98) + label.textColor = .mainRed + label.shadowColor = UIColor(rgb: 0xB50000) + label.textAlignment = .center + label.text = "3" + label.isHidden = true + return label + }() + + private let viewModel: CallViewModel + private var timer: Timer.TimerPublisher? + private var cancellables = Set() + + + required init(viewModel: CallViewModel) { + self.viewModel = viewModel + super.init(frame: CGRect.zero) + setUpConstriants() + setUpStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setUpConstriants() { + self.addSubview(timeLabel) + timeLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + timeLabel.centerXAnchor.constraint(equalTo: self.centerXAnchor), + timeLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor), + timeLabel.widthAnchor.constraint(equalToConstant:100), + timeLabel.heightAnchor.constraint(equalToConstant:100) + ]) + } + + private func setUpStyle() { + isUserInteractionEnabled = false + backgroundColor = .mainRed.withAlphaComponent(0.0) + } + + func timeCountAnimation() { + timeLabel.isHidden = false + backgroundAlphaAnimation() + timer = Timer.publish(every: 1,tolerance: 0.9, on: .main, in: .default) + timer? + .autoconnect() + .scan(0) { counter, _ in counter + 1 } + .sink { [self] counter in + if counter == 3 { + viewModel.isCallSucceed() + timer?.connect().cancel() + } else { + timeLabel.text = "\(3 - counter)" + } + }.store(in: &cancellables) + } + + func cancelTimeCount() { + timer?.connect().cancel() + backgroundColor = .mainRed.withAlphaComponent(0) + timeLabel.isHidden = true + timeLabel.text = "3" + } + + private func backgroundAlphaAnimation() { + UIView.animate(withDuration: 3.0, delay: 0.0, animations: { + self.backgroundColor = .mainRed.withAlphaComponent(0.5) + }) + + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Dispatch/DispatchDescriptionView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Dispatch/DispatchDescriptionView.swift new file mode 100644 index 0000000..276df05 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Dispatch/DispatchDescriptionView.swift @@ -0,0 +1,87 @@ +// +// DispatchDescriptionView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/30. +// + +import UIKit + +enum DispatchDescriptionType: String { + case duration = "Duration" + case distance = "Distance" +} + +class DispatchDescriptionView: UIView { + + private lazy var imageView: UIImageView = { + let view = UIImageView() + return view + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 14) + label.textAlignment = .center + label.textColor = .black + return label + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 18) + label.textAlignment = .center + label.textColor = .black + label.text = "---" + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setUpConstraints() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + func setUpConstraints() { + [ + imageView, + titleLabel, + descriptionLabel + ].forEach({ + self.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + $0.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true + }) + + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: self.topAnchor), + imageView.widthAnchor.constraint(equalToConstant: 24), + imageView.heightAnchor.constraint(equalToConstant: 24) + ]) + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor), + titleLabel.widthAnchor.constraint(equalToConstant: 75), + titleLabel.heightAnchor.constraint(equalToConstant: 20) + ]) + + NSLayoutConstraint.activate([ + descriptionLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2), + descriptionLabel.widthAnchor.constraint(equalToConstant: 75), + descriptionLabel.heightAnchor.constraint(equalToConstant: 25) + ]) + } + + func setUpComponent(imageName: String, type: DispatchDescriptionType) { + imageView.image = UIImage(named: imageName) + imageView.contentMode = .scaleAspectFit + titleLabel.text = type.rawValue + } + + func setUpDescription(text: String) { + descriptionLabel.text = text + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Dispatch/DispatchTimerView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Dispatch/DispatchTimerView.swift new file mode 100644 index 0000000..51622af --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Dispatch/DispatchTimerView.swift @@ -0,0 +1,106 @@ +// +// DispatchTimerView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/31. +// + +import Combine +import UIKit + +class DispatchTimerView: UIView { + + private let calledTime: Date? + + private let timeImageView: UIImageView = { + let view = UIImageView() + view.image = UIImage(named: "time.png") + return view + }() + + private lazy var timeLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 48) + label.textColor = .mainRed + label.textAlignment = .right + label.text = "00:00" + return label + }() + + private var timer: Timer.TimerPublisher? + private var cancellables = Set() + + init(calledTime: Date) { + self.calledTime = calledTime + super.init(frame: CGRect.zero) + setUpConstraints() + setUpStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setUpConstraints() { + + let timeStackView = UIStackView() + timeStackView.axis = NSLayoutConstraint.Axis.horizontal + timeStackView.distribution = UIStackView.Distribution.equalSpacing + timeStackView.alignment = UIStackView.Alignment.center + timeStackView.spacing = 8 + + self.addSubview(timeStackView) + timeStackView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + timeStackView.centerXAnchor.constraint(equalTo: self.centerXAnchor), + timeStackView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + timeStackView.widthAnchor.constraint(equalToConstant: 182), + timeStackView.heightAnchor.constraint(equalToConstant: 50) + ]) + + [ + timeImageView, + timeLabel + ].forEach({ + timeStackView.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + $0.centerYAnchor.constraint(equalTo: timeStackView.centerYAnchor).isActive = true + }) + + NSLayoutConstraint.activate([ + timeImageView.leadingAnchor.constraint(equalTo: timeStackView.leadingAnchor), + timeImageView.widthAnchor.constraint(equalToConstant: 50), + timeImageView.heightAnchor.constraint(equalToConstant: 50) + ]) + + NSLayoutConstraint.activate([ + timeLabel.trailingAnchor.constraint(equalTo: timeStackView.trailingAnchor), + timeLabel.widthAnchor.constraint(equalToConstant: 182), + timeLabel.heightAnchor.constraint(equalToConstant: 50) + ]) + } + + private func setUpStyle() { + backgroundColor = .white + } + + func setTimer() { + timer = Timer.publish(every: 1,tolerance: 0.9, on: .main, in: .default) + timer? + .autoconnect() + .scan(0) { counter, _ in counter + 1 } + .sink { [self] counter in + if counter == 301 { + timer?.connect().cancel() + } else { + timeLabel.text = counter.numberAsTime() + } + }.store(in: &cancellables) + } + + func cancelTimer() { + timer?.connect().cancel() + timer = nil + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Dispatch/DispatchViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Dispatch/DispatchViewController.swift new file mode 100644 index 0000000..b50ea59 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Dispatch/DispatchViewController.swift @@ -0,0 +1,287 @@ +// +// DispatchViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/30. +// + +import Combine +import CombineCocoa +import GoogleMaps +import UIKit + +struct CallerCompactInfo { + let callerId: Int + let latitude: Double + let longitude: Double + let callerAddress: String +} + +final class DispatchViewController: UIViewController { + private let callerLocationNoticeView = CurrentLocationNoticeView() + + private let stackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = NSLayoutConstraint.Axis.horizontal + stackView.distribution = UIStackView.Distribution.equalSpacing + stackView.alignment = UIStackView.Alignment.center + stackView.spacing = 12 + stackView.layer.borderColor = UIColor(rgb: 0x938C8C).cgColor + stackView.layer.cornerRadius = 16 + stackView.layer.borderWidth = 1 + return stackView + }() + + private let stackViewDecoLine: UIView = { + let view = UIView() + view.backgroundColor = .mainLightGray + return view + }() + + private let durationView: DispatchDescriptionView = { + let view = DispatchDescriptionView() + view.setUpComponent(imageName: "time_check.png", type: .duration) + return view + }() + + private let distanceView: DispatchDescriptionView = { + let view = DispatchDescriptionView() + view.setUpComponent(imageName: "map.png", type: .distance) + return view + }() + + private lazy var dispatchTimerView: DispatchTimerView = { + let view = DispatchTimerView(calledTime: Date()) + view.layer.borderColor = UIColor(rgb: 0x938C8C).cgColor + view.layer.cornerRadius = 16 + view.layer.borderWidth = 1 + view.isHidden = true + return view + }() + + private let dispatchButton: UIButton = { + let button = UIButton() + button.titleLabel?.font = UIFont(weight: .bold, size: 18) + button.setTitleColor(.white, for: .normal) + button.backgroundColor = .mainRed + button.layer.cornerRadius = 27.5 + button.setTitle("DISPATCH", for: .normal) + return button + }() + + private let reportLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 14) + label.textColor = .mainBlack + label.textAlignment = .right + label.text = "Wrong Report? Report" + label.isHidden = true + label.isUserInteractionEnabled = true + return label + }() + + private let manager = DispatchManager(service: APIManager()) + private let callerInfo: CallerCompactInfo + private let userLocation: CLLocationCoordinate2D + private var dispatchId: Int? + private var isDispatched: Bool = false + private var cancellables = Set() + + init (userLocation: CLLocationCoordinate2D, callerInfo: CallerCompactInfo) { + self.userLocation = userLocation + self.callerInfo = callerInfo + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setUpConstraints() + setUpStyle() + setUpComponent() + bind() + setUpAction() + setupSheet() + calculateDurationNDistance() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + dispatchTimerView.cancelTimer() + } + private func setUpConstraints() { + let make = Constraints.shared + let safeArea = view.safeAreaLayoutGuide + + [ + callerLocationNoticeView, + stackView, + dispatchTimerView, + dispatchButton, + reportLabel + ].forEach({ + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + $0.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true + }) + + NSLayoutConstraint.activate([ + callerLocationNoticeView.topAnchor.constraint(equalTo: view.topAnchor, constant: 26), + callerLocationNoticeView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: make.space16), + callerLocationNoticeView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -make.space16), + callerLocationNoticeView.heightAnchor.constraint(equalToConstant: 50) + ]) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: callerLocationNoticeView.bottomAnchor, constant: make.space8), + stackView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: make.space8), + stackView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -make.space8), + stackView.heightAnchor.constraint(equalToConstant: 108) + ]) + + NSLayoutConstraint.activate([ + dispatchTimerView.topAnchor.constraint(equalTo: callerLocationNoticeView.bottomAnchor, constant: make.space8), + dispatchTimerView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: make.space8), + dispatchTimerView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -make.space8), + dispatchTimerView.heightAnchor.constraint(equalToConstant: 108) + ]) + + [ + durationView, + stackViewDecoLine, + distanceView, + ].forEach({ + stackView.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + $0.centerYAnchor.constraint(equalTo: stackView.centerYAnchor).isActive = true + }) + + NSLayoutConstraint.activate([ + stackViewDecoLine.centerXAnchor.constraint(equalTo: stackView.centerXAnchor), + stackViewDecoLine.widthAnchor.constraint(equalToConstant: 1), + stackViewDecoLine.heightAnchor.constraint(equalToConstant: 100) + ]) + + NSLayoutConstraint.activate([ + durationView.leadingAnchor.constraint(equalTo: stackView.leadingAnchor, constant: 56), + durationView.widthAnchor.constraint(equalToConstant: 75), + durationView.heightAnchor.constraint(equalToConstant: 70) + ]) + + NSLayoutConstraint.activate([ + distanceView.trailingAnchor.constraint(equalTo: stackView.trailingAnchor, constant: -56), + distanceView.widthAnchor.constraint(equalToConstant: 75), + distanceView.heightAnchor.constraint(equalToConstant: 70) + ]) + + NSLayoutConstraint.activate([ + dispatchButton.topAnchor.constraint(equalTo: stackView.bottomAnchor, constant: make.space16), + dispatchButton.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: make.space8), + dispatchButton.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -make.space8), + dispatchButton.heightAnchor.constraint(equalToConstant: 55) + ]) + + NSLayoutConstraint.activate([ + reportLabel.topAnchor.constraint(equalTo: dispatchButton.bottomAnchor, constant: make.space12), + reportLabel.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: make.space8), + reportLabel.trailingAnchor.constraint(equalTo: dispatchButton.trailingAnchor), + reportLabel.heightAnchor.constraint(equalToConstant: 20) + ]) + } + + private func setUpStyle() { + view.backgroundColor = .white + } + + private func setUpComponent() { + callerLocationNoticeView.setUpLocationLabelText(as: callerInfo.callerAddress) + } + + private func bind() { + dispatchButton.tapPublisher.sink { [self] in + if isDispatched { + Task { + guard let dispatchId = dispatchId else { return } + let result = try await manager.dispatchEnd(dispatchId: dispatchId) + if result.success { + dismiss(animated: true) + } + } + } else { + Task { + let result = try await manager.dispatchAccept(cprCallId: callerInfo.callerId) + if result.success { + dispatchId = result.data?.dispatch_id + isModalInPresentation = true + dispatchButton.setTitle("ARRIVED", for: .normal) + stackView.isHidden = true + reportLabel.isHidden = false + timerAppear() + dispatchTimerView.setTimer() + isDispatched = true + } + } + } + }.store(in: &cancellables) + } + + private func setupSheet() { + + if let sheet = sheetPresentationController { + sheet.detents = [.custom { _ in return 300 }] + sheet.selectedDetentIdentifier = .medium + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 18 + } + } + + private func timerAppear() { + UIView.animate(withDuration: 0.2, animations: { + self.dispatchTimerView.isHidden = false + }) + } + + private func setUpAction() { + let gesture = UITapGestureRecognizer(target: self, action: #selector(didTapReportButton)) + reportLabel.addGestureRecognizer(gesture) + } + + @objc func didTapReportButton() { + guard let dispatchId = dispatchId else { return } + let vc = ReportViewController(dispatchId: dispatchId, manager: manager) + vc.modalPresentationStyle = .fullScreen + present(vc, animated: true) + } +} + +extension DispatchViewController { + func calculateDurationNDistance() { + let callerLocation = CLLocationCoordinate2D(latitude: callerInfo.latitude, longitude: callerInfo.longitude) + let rawDistance = GMSGeometryDistance(userLocation, callerLocation) + + let floorDistance = floor(rawDistance) + var duration: Int = 0 + var distanceStr = "" + if floorDistance < 100 { + duration = 1 + } else { + duration = Int(ceil(Float(rawDistance/100))) + if floorDistance < 1000 { + distanceStr = "\(floorDistance)m" + } else { + let distance: Double = rawDistance/1000 + distanceStr = String(format: "%.2f", distance) + "km" + } + } + + print("RAW: \(rawDistance)") + print("FLOOR: \(floorDistance)") + print(distanceStr) + durationView.setUpDescription(text: "\(duration)m") + distanceView.setUpDescription(text: distanceStr) + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Dispatch/ReportViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Dispatch/ReportViewController.swift new file mode 100644 index 0000000..9b96d6e --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Dispatch/ReportViewController.swift @@ -0,0 +1,183 @@ +// +// ReportViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/31. +// + +import Combine +import CombineCocoa +import UIKit + +final class ReportViewController: UIViewController { + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 24) + label.textAlignment = .left + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.5 + label.textColor = .mainBlack + label.text = "What are you trying to report?" + return label + }() + + private let descriptionLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 14) + label.textAlignment = .left + label.textColor = .mainBlack + label.text = "Your report will be treated anonymously." + return label + }() + + private let placeHolder = "Content*" + private let reportTextView: UITextView = { + let view = UITextView() + view.layer.cornerRadius = 6 + view.layer.borderWidth = 1.0 + view.layer.borderColor = UIColor.black.withAlphaComponent(0.12).cgColor + view.textContainerInset = UIEdgeInsets(top: 8.0, left: 8.0, bottom: 8.0, right: 8.0) + view.font = .systemFont(ofSize: 18) + view.text = "Content*" + view.textColor = .lightGray + return view + }() + + private var submitButtonBottomConstraint = NSLayoutConstraint() + private let submitButton: UIButton = { + let button = UIButton() + button.titleLabel?.font = UIFont(weight: .bold, size: 18) + button.setTitleColor(.white, for: .normal) + button.backgroundColor = .mainRed + button.layer.cornerRadius = 27.5 + button.setTitle("SUBMIT", for: .normal) + return button + }() + + private let dispatchId: Int + private let manager: DispatchManager + private var cancellables = Set() + + init(dispatchId: Int, manager: DispatchManager) { + self.dispatchId = dispatchId + self.manager = manager + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setUpConstraints() + setUpStyle() + setUpDelegate() + setUpAction() + setUpKeyboard() + } + + private func setUpConstraints() { + let safeArea = view.safeAreaLayoutGuide + let make = Constraints.shared + [ + titleLabel, + descriptionLabel, + reportTextView, + submitButton + + ].forEach({ + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + $0.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true + }) + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: make.space16), + titleLabel.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor), + titleLabel.widthAnchor.constraint(equalToConstant: 340), + titleLabel.heightAnchor.constraint(equalToConstant: 30) + ]) + + NSLayoutConstraint.activate([ + descriptionLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: make.space16), + descriptionLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + descriptionLabel.widthAnchor.constraint(equalToConstant: 340), + descriptionLabel.heightAnchor.constraint(equalToConstant: 30) + ]) + + NSLayoutConstraint.activate([ + reportTextView.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: make.space18), + reportTextView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: make.space16), + reportTextView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -make.space16), + reportTextView.heightAnchor.constraint(equalToConstant: 180) + ]) + + submitButtonBottomConstraint = submitButton.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -make.space16) + NSLayoutConstraint.activate([ + submitButtonBottomConstraint, + submitButton.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: make.space8), + submitButton.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -make.space8), + submitButton.heightAnchor.constraint(equalToConstant: 55) + ]) + } + + private func setUpStyle() { + view.backgroundColor = .white + } + + private func setUpDelegate() { + reportTextView.delegate = self + } + + private func setUpAction() { + submitButton.tapPublisher.sink { [self] in + Task { + let reportInfo = ReportInfo(content: reportTextView.text, dispatch_id: dispatchId) + let result = try await manager.userReport(reportInfo: reportInfo) + if result.success { + dismiss(animated: true) + } + + } + }.store(in: &cancellables) + } + + private func setUpKeyboard() { + NotificationCenter.default.addObserver(self, selector:#selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector:#selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil) + hideKeyboardWhenTappedAround() + } + + @objc private func keyboardWillShow(_ notification: Notification) { + if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { + let keyboardHeight = keyboardFrame.cgRectValue.height + + submitButtonBottomConstraint.constant = -keyboardHeight + view.layoutIfNeeded() + } + } + + @objc private func keyboardWillHide(_ notification: Notification) { + submitButtonBottomConstraint.constant = -16 + view.layoutIfNeeded() + } +} + +extension ReportViewController: UITextViewDelegate { + func textViewDidBeginEditing(_ textView: UITextView) { + if textView.text == placeHolder { + textView.text = nil + textView.textColor = .black + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + if textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + textView.text = placeHolder + textView.textColor = .lightGray + } + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/EducationViewModel.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/EducationViewModel.swift new file mode 100644 index 0000000..9cde82c --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/EducationViewModel.swift @@ -0,0 +1,419 @@ +// +// EducationViewModel.swift +// CPR2U +// +// Created by 황정현 on 2023/03/20. +// + +import Combine +import UIKit + +enum CompressionRateStatus: String { + case tooSlow = "Too Slow" + case slow = "Slow" + case adequate = "Adequate" + case fast = "Fast" + case tooFast = "Too Fast" + case wrong = "Wrong" + + // 압박 속도 + // 190-250 : 33점 + // 170-270 : 22점 + // 150-290 : 11점 + var score: Int { + switch self { + case .adequate: + return 33 + case .slow, .fast: + return 22 + case .tooSlow, .tooFast: + return 11 + case .wrong: + return 0 + } + } + + var description: String { + switch self { + case .tooSlow: + return "It's too slow. Press faster" + case .slow: + return "It's slow. Press more faster" + case .adequate: + return "Good job! Very Adequate" + case .fast: + return "It's fast. Press more slower" + case .tooFast: + return "It's too fast. Press slower" + case .wrong: + return "Something went wrong. Try Again" + } + } +} + +enum AngleStatus: String { + case adequate = "Adequate" + case almost = "Almost Adequate" + case notGood = "Not Good" + case bad = "Bad" + + // 팔 각도 + // CORRECT : NON-CORRECT + // 7:3 : 33점 + // 6:4 : 22점 + // 5:5 : 11점 + // 나머지 : 5점 + var score: Int { + switch self { + case .adequate: + return 33 + case .almost: + return 22 + case .notGood: + return 11 + case .bad: + return 5 + } + } + + var description: String { + switch self { + case .adequate: + return "Good job! Very Nice angle!" + case .almost: + return "Almost there. Try again" + case .notGood: + return "Pay more attention to the angle of your arms" + case .bad: + return "You need some more practice" + } + } +} + +enum PressDepthStatus: String { + case deep = "Deep" + case adequate = "Adequate" + case shallow = "Slightly Shallow" + case tooShallow = "Too Shallow" + case wrong = "Wrong" + + // 압박 깊이 + // 30 이상 : 15 + // 18 - 30 : 33 + // 5 - 18 : 15 + // 0 - 5. : 5 + var score: Int { + switch self { + case .deep: + return 15 + case .adequate: + return 33 + case .shallow: + return 15 + case .tooShallow: + return 5 + case .wrong: + return 0 + } + } + + var description: String { + switch self { + case .deep: + return "Press slight" + case .adequate: + return "Good job! Very adequate!" + case .shallow: + return "Press little deeper" + case .tooShallow: + return "It's too shallow. Press deeply" + case .wrong: + return "Something went wrong. Try Again" + + } + } +} + +struct CertificateStatus { + let status: AngelStatus + let leftDay: Int? +} + +enum AngelStatus: Int { + case acquired + case expired + case unacquired + + func certificationImageName(_ isBig: Bool = false) -> String { + switch self { + case .acquired: + return isBig == true ? "heart_person_big" : "heart_person" + case .expired, .unacquired: + return isBig == true ? "person_big" : "person" + } + } + + func certificationStatus() -> String { + switch self { + case .acquired: + return "ACQUIRED" + case .expired: + return "EXPIRED" + case .unacquired: + return "UNACQUIRED" + } + } +} + +enum TimerType: Int { + case lecture = 3001 + case posture = 130 + case other = 0 +} + +final class EducationViewModel: AsyncOutputOnlyViewModelType { + + private let eduManager: EducationManager + + private let eduName: [String] = ["Lecture" , "Quiz", "Pose Practice"] + private let eduDescription: [String] = ["Video lecture for CPR angel certificate", "Let’s check your CPR study", "Posture practice to get CPR angel certificate"] + private var eduStatusArr:[CurrentValueSubject] = [CurrentValueSubject(false), CurrentValueSubject(false), CurrentValueSubject(false)] + private var input: Input? + + private var currentTimerType = TimerType.other + var timer = Timer.publish(every: 1, on: .current, in: .common) + + private var compressionRate: Int? + private var angleRate: (correct: Int?, nonCorrect: Int?) + private var pressDepthRate: CGFloat? + + init() { + eduManager = EducationManager(service: APIManager()) + Task { + self.input = try await initialize() ?? nil + } + } + + struct Input { + let nickname: CurrentValueSubject + let angelStatus: CurrentValueSubject + let progressPercent: CurrentValueSubject + let leftDay: CurrentValueSubject + let isLectureCompleted: CurrentValueSubject + let isQuizCompleted: CurrentValueSubject + let isPostureCompleted: CurrentValueSubject + } + + struct Output { + let nickname: CurrentValueSubject? + let certificateStatus: CurrentValueSubject? + let progressPercent: CurrentValueSubject? + } + + func educationName() -> [String] { + return eduName + } + + func educationDescription() -> [String] { + return eduDescription + } + + func educationStatus() -> [CurrentValueSubject] { + return eduStatusArr + } + + func timeLimit() -> Int { + currentTimerType.rawValue + } + + func transform() async throws -> Output { + + let output = Task { () -> Output in + let userInfo = try await receiveEducationStatus() + updateInput(data: userInfo) + + let certificateStatus: CurrentValueSubject = { + guard let status = AngelStatus(rawValue: input?.angelStatus.value ?? 2) else { + return CurrentValueSubject(CertificateStatus(status: AngelStatus.unacquired, leftDay: nil)) + } + + guard let leftDayNum = input?.leftDay.value else { + return CurrentValueSubject(CertificateStatus(status: status, leftDay: nil)) + } + + return CurrentValueSubject(CertificateStatus(status: status, leftDay: leftDayNum)) + + }() + + return Output(nickname: input?.nickname, certificateStatus: certificateStatus, progressPercent: input?.progressPercent) + } + + return try await output.value + + + } + + func updateTimerType(vc: UIViewController) { + if (vc as? LectureViewController) != nil { + currentTimerType = .lecture + } else if (vc as? PosePracticeViewController) != nil { + currentTimerType = .posture + } + } + + func receiveEducationStatus() async throws -> UserInfo? { + let result = Task { () -> UserInfo? in + let eduResult = try await eduManager.getEducationProgress() + return eduResult.data + } + + let data = try await result.value + + return data + } + + func initialize() async throws -> Input? { + let result = Task { () -> Input? in + let eduResult = try await self.eduManager.getEducationProgress() + guard let data = eduResult.data else { return nil } + + let progressPercent = Float(data.progress_percent) + let isLectureCompleted = data.is_lecture_completed == 2 + let isQuizCompleted = data.is_quiz_completed == 2 + let isPostureCompleted = data.is_posture_completed == 2 + + let isCompleted = [isLectureCompleted, isQuizCompleted, isPostureCompleted] + for i in 0.. Bool { + let result = Task { + let eduResult = try await eduManager.saveLectureProgress(lectureId: 1) + let userInfo = try await receiveEducationStatus() + updateInput(data: userInfo) + return eduResult.success + } + return try await result.value + } + + func savePosturePracticeResult(score: Int) async throws -> Bool { + let result = Task { + let eduResult = try await eduManager.savePosturePracticeResult(score: score) + let userInfo = try await receiveEducationStatus() + updateInput(data: userInfo) + return eduResult.success + } + return try await result.value + } + + func getLecture() async throws -> String? { + let result = Task { () -> String? in + let eduResult = try await eduManager.getLecture() + return eduResult.data?.lecture_list[0].video_url + } + return try await result.value + } + + func getPostureLecture() async throws -> String? { + let result = Task { () -> String? in + let eduResult = try await eduManager.getPostureLecture() + return eduResult.data?.video_url + } + return try await result.value + } + + func updateInput(data: UserInfo?) { + let progressPercent = Float(data?.progress_percent ?? 0) + let isLectureCompleted = data?.is_lecture_completed == 2 + let isQuizCompleted = data?.is_quiz_completed == 2 + let isPostureCompleted = data?.is_posture_completed == 2 + + DispatchQueue.main.async { [weak self] in + self?.input?.nickname.send(data?.nickname ?? "") + self?.input?.angelStatus.send(data?.angel_status ?? 0) + self?.input?.progressPercent.send(progressPercent) + self?.input?.leftDay.send(data?.days_left_until_expiration ?? nil) + self?.input?.isLectureCompleted.send(isLectureCompleted) + self?.input?.isQuizCompleted.send(isQuizCompleted) + self?.input?.isPostureCompleted.send(isPostureCompleted) + + let isCompleted = [isLectureCompleted, isQuizCompleted, isPostureCompleted] + guard let count = self?.eduStatusArr.count else { return } + for i in 0.. (compResult: CompressionRateStatus, angleResult: AngleStatus, pressDepth: PressDepthStatus) { + guard let compRate = compressionRate, let correct = angleRate.correct, let nonCorrect = angleRate.nonCorrect, let pressRate = pressDepthRate else { return (CompressionRateStatus.wrong, AngleStatus.bad, PressDepthStatus.shallow) } + + var compResult: CompressionRateStatus = .adequate + switch compRate { + case ...170: + compResult = .tooSlow + case 170...190: + compResult = .slow + case 190...250: + compResult = .adequate + case 250...270: + compResult = .fast + case 270...: + compResult = .tooFast + default: + compResult = .wrong + } + + let angleRate = angleRate + + var angleResult: AngleStatus = .adequate + let totalAngleCount = Double(correct + nonCorrect) + + switch Double(correct) { + case Double(totalAngleCount) * 0.7...totalAngleCount: + angleResult = .adequate + case Double(totalAngleCount) * 0.6...Double(totalAngleCount) * 0.7: + angleResult = .almost + case Double(totalAngleCount) * 0.5...Double(totalAngleCount) * 0.6: + angleResult = .notGood + default: + angleResult = .bad + } + + print(totalAngleCount) + if totalAngleCount < 1000 { + angleResult = .bad + } + + var pressDepthResult: PressDepthStatus = .wrong + + switch pressRate { + case 30.0... : + pressDepthResult = .deep + case 18.0..<30.0: + pressDepthResult = .adequate + case 5.0..<18.0: + pressDepthResult = .shallow + case 0.0..<5.0: + pressDepthResult = .tooShallow + default: + pressDepthResult = .wrong + } + + return (compResult, angleResult, pressDepthResult) + } + + func setPostureResult(compCount: Int, armAngleCount: (correct: Int, nonCorrect: Int), pressDepth: CGFloat) { + compressionRate = compCount + angleRate.correct = armAngleCount.correct + angleRate.nonCorrect = armAngleCount.nonCorrect + pressDepthRate = pressDepth + + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Lecture/LectureViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Lecture/LectureViewController.swift new file mode 100644 index 0000000..1d27473 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Lecture/LectureViewController.swift @@ -0,0 +1,124 @@ +// +// LectureViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/24. +// + +import Combine +import UIKit +import WebKit + +final class LectureNoticeView: CustomNoticeView { + @objc internal override func didConfirmButtonTapped() { } +} + +final class LectureViewController: UIViewController { + + private let webView = WKWebView() + + private let noticeView: LectureNoticeView = { + let view = LectureNoticeView(noticeAs: .pf) + view.setPFResultNotice(isPass: true) + return view + }() + + private var viewModel: EducationViewModel + private var cancellables = Set() + + init(viewModel: EducationViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + viewModel.updateTimerType(vc: self) + setUpConstraints() + setUpStyle() + loadWebPage() + setTimer() + bind(viewModel: viewModel) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(true) + navigationController?.navigationBar.prefersLargeTitles = false + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(true) + navigationController?.navigationBar.prefersLargeTitles = true + viewModel.timer.connect().cancel() + } + + private func setUpConstraints() { + let safeArea = view.safeAreaLayoutGuide + + [ + webView, + noticeView + ].forEach({ + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: safeArea.topAnchor), + webView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor), + webView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor), + ]) + + NSLayoutConstraint.activate([ + noticeView.topAnchor.constraint(equalTo: view.topAnchor), + noticeView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + noticeView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + noticeView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + private func setUpStyle() { + view.backgroundColor = .white + } + + private func loadWebPage() { + Task { + guard let url = try await viewModel.getLecture() else { return } + guard let stringToURL = URL(string: url) else { return } + let URLToRequest = URLRequest(url: stringToURL) + webView.load(URLToRequest) + } + } + + private func setTimer() { + let count = viewModel.timeLimit() + viewModel.timer + .autoconnect() + .scan(0) { counter, _ in counter + 1 } + .sink { [self] counter in + if counter == count + 1 { + noticeView.noticeAppear() + viewModel.timer.connect().cancel() + } + }.store(in: &cancellables) + } + + private func bind(viewModel: EducationViewModel) { + noticeView.confirmButton.tapPublisher.sink { [weak self] in + Task { + _ = try await viewModel.saveLectureProgress() + self?.noticeView.noticeDisappear() + if let vc = self?.navigationController?.viewControllers[0] as? EducationMainViewController { + vc.educationCollectionView.reloadData() + } + self?.navigationController?.popViewController(animated: true) + } + }.store(in: &cancellables) + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Main/AddressSettingView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Main/AddressSettingView.swift new file mode 100644 index 0000000..a43129b --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Main/AddressSettingView.swift @@ -0,0 +1,277 @@ +// +// AddressSettingView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/29. +// + +import Combine +import CombineCocoa +import UIKit + +// TODO: 추후 CustomNoticeView 상속 받아서 사용하는 형태로 변경하기 +final class AddressSettingView: UIView { + + private lazy var shadowView: UIView = { + let view = UIView() + + view.backgroundColor = UIColor(rgb: 0x7B7B7B).withAlphaComponent(0.45) + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: view.topAnchor), + view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + return view + } () + + private let noticeView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(rgb: 0xFCFCFC) + view.layer.cornerRadius = 20 + return view + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 16) + label.textAlignment = .center + label.numberOfLines = 2 + label.textColor = .mainBlack + label.text = "Select your address for\nCPR Angel activities" + return label + }() + + private lazy var mainAddressTextField: UITextField = { + let textField = UITextField() + textField.font = UIFont(weight: .bold, size: 24) + textField.textColor = UIColor(rgb: 0xC1C1C1) + textField.textAlignment = .center + textField.tintColor = .clear + textField.text = "시/도" + return textField + }() + + private lazy var subAddressTextField: UITextField = { + let textField = UITextField() + textField.font = UIFont(weight: .bold, size: 24) + textField.textColor = UIColor(rgb: 0xC1C1C1) + textField.textAlignment = .center + textField.tintColor = .clear + textField.text = "구/군" + return textField + }() + + let confirmButton: UIButton = { + let button = UIButton() + button.layer.cornerRadius = 22 + button.backgroundColor = .mainRed + button.titleLabel?.font = UIFont(weight: .bold, size: 17) + button.setTitleColor(.mainWhite, for: .normal) + button.setTitle("CONFIRM", for: .normal) + return button + }() + + private let appearAnimDuration: CGFloat = 0.4 + + private var addressList: [AddressListResult] = [] + private var mainAddressIndex: Int? + private var addressId: Int? + private let addressManager = AddressManager(service: APIManager()) + private var cancellables = Set() + + init() { + super.init(frame: CGRect.zero) + setUpConstraints() + setUpStyle() + setUpAction() + + Task { + setUpAddreessList() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setUpConstraints() { + self.addSubview(noticeView) + noticeView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + noticeView.centerXAnchor.constraint(equalTo: self.centerXAnchor), + noticeView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + noticeView.widthAnchor.constraint(equalToConstant: 313), + noticeView.heightAnchor.constraint(equalToConstant: 308) + ]) + + [ + titleLabel, + mainAddressTextField, + subAddressTextField, + confirmButton + ].forEach({ + noticeView.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: noticeView.topAnchor, constant: 26), + titleLabel.centerXAnchor.constraint(equalTo: noticeView.centerXAnchor), + titleLabel.widthAnchor.constraint(equalTo: noticeView.widthAnchor), + titleLabel.heightAnchor.constraint(equalToConstant: 48) + ]) + + NSLayoutConstraint.activate([ + mainAddressTextField.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 32), + mainAddressTextField.centerXAnchor.constraint(equalTo: noticeView.centerXAnchor), + mainAddressTextField.widthAnchor.constraint(equalTo: noticeView.widthAnchor), + mainAddressTextField.heightAnchor.constraint(equalToConstant: 34) + ]) + + NSLayoutConstraint.activate([ + subAddressTextField.topAnchor.constraint(equalTo: mainAddressTextField.bottomAnchor, constant: 24), + subAddressTextField.centerXAnchor.constraint(equalTo: noticeView.centerXAnchor), + subAddressTextField.widthAnchor.constraint(equalTo: noticeView.widthAnchor), + subAddressTextField.heightAnchor.constraint(equalToConstant: 34) + ]) + + NSLayoutConstraint.activate([ + confirmButton.bottomAnchor.constraint(equalTo: noticeView.bottomAnchor, constant: -26), + confirmButton.centerXAnchor.constraint(equalTo: self.centerXAnchor), + confirmButton.widthAnchor.constraint(equalToConstant: 206), + confirmButton.heightAnchor.constraint(equalToConstant: 44) + ]) + + } + + private func setUpStyle() { + self.alpha = 0.0 + self.backgroundColor = UIColor(rgb: 0x7B7B7B).withAlphaComponent(0.45) + } + + private func setUpAddreessList() { + Task { + let result = try await addressManager.getAddressList() + if result.success == true { + guard let list = result.data else { return } + addressList = list + setUpPickerView() + } + + } + } + + private func setUpPickerView() { + let mainPickerView = UIPickerView() + mainPickerView.layer.name = "mainPickerView" + let subPickerView = UIPickerView() + subPickerView.layer.name = "subPickerView" + + [mainPickerView, subPickerView].forEach({ + $0.delegate = self + $0.dataSource = self + }) + + [mainAddressTextField, subAddressTextField].forEach({ + let toolBar = UIToolbar() + toolBar.sizeToFit() + let button = UIBarButtonItem(title: "완료", style: .plain, target: self, action: #selector(self.didSelectButtonTapped)) + toolBar.setItems([button], animated: true) + toolBar.isUserInteractionEnabled = true + $0.inputAccessoryView = toolBar + }) + + mainAddressTextField.inputView = mainPickerView + subAddressTextField.inputView = subPickerView + } + + private func setUpAction() { + confirmButton.tapPublisher.sink { + Task { + guard let id = self.addressId else { + return } + try await self.addressManager.setUserAddress(id: id) + self.noticeDisappear() + } + }.store(in: &cancellables) + } + + @objc func didSelectButtonTapped() { + mainAddressTextField.endEditing(true) + subAddressTextField.endEditing(true) + } + + func noticeAppear() { + self.superview?.isUserInteractionEnabled = false + UIView.animate(withDuration: appearAnimDuration, animations: { + self.alpha = 1.0 + }, completion: { _ in + self.superview?.isUserInteractionEnabled = true + }) + } + + func noticeDisappear() { + UIView.animate(withDuration: appearAnimDuration/2, delay: 0, animations: { + self.alpha = 0.0 + }, completion: { [weak self] _ in + self?.superview?.isUserInteractionEnabled = true + self?.removeFromSuperview() + }) + } +} + +extension AddressSettingView: UIPickerViewDelegate, UIPickerViewDataSource { + func numberOfComponents(in pickerView: UIPickerView) -> Int { + if pickerView.layer.name == "mainPickerView" { + return 1 + } else if pickerView.layer.name == "subPickerView" { + return 1 + } + return 1 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + if pickerView.layer.name == "mainPickerView" { + return addressList.count + } else if pickerView.layer.name == "subPickerView" { + guard let index = mainAddressIndex else { return 0 } + return addressList[index].gugun_list.count + } + return 0 + } + + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + if pickerView.layer.name == "mainPickerView" { + return addressList[row].sido + } else if pickerView.layer.name == "subPickerView" { + guard let index = mainAddressIndex else { return "" } + return addressList[index].gugun_list[row].gugun + } + return nil + } + + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + if pickerView.layer.name == "mainPickerView" { + mainAddressTextField.text = addressList[row].sido + mainAddressTextField.textColor = .mainBlack + if addressList[row].sido == "세종특별자치시" { + addressId = addressList[row].gugun_list[0].id + subAddressTextField.isHidden = true + } else { + addressId = nil + subAddressTextField.text = "구/군" + subAddressTextField.textColor = UIColor(rgb: 0xC1C1C1) + subAddressTextField.isHidden = false + } + mainAddressIndex = row + } else if pickerView.layer.name == "subPickerView" { + guard let index = mainAddressIndex else { return } + subAddressTextField.text = addressList[index].gugun_list[row].gugun + subAddressTextField.textColor = .mainBlack + addressId = addressList[index].gugun_list[row].id + } + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Main/CertificateStatusView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Main/CertificateStatusView.swift new file mode 100644 index 0000000..4264c4b --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Main/CertificateStatusView.swift @@ -0,0 +1,128 @@ +// +// CertificateStatusView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/09. +// + +import UIKit + +final class CertificateStatusView: UIView { + + private let status: AngelStatus = .unacquired + private let certificateImage = UIImageView() + private lazy var greetingLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 14) + label.textColor = .mainBlack + return label + }() + + private lazy var certificateLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 14) + label.textColor = .mainBlack + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + setUpConstraints() + setUpStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setUpConstraints() { + + let make = Constraints.shared + + let labelStackView = UIStackView() + labelStackView.axis = NSLayoutConstraint.Axis.vertical + labelStackView.distribution = UIStackView.Distribution.equalSpacing + labelStackView.alignment = UIStackView.Alignment.center + + [ + certificateImage, + labelStackView + ].forEach({ + self.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + certificateImage.leadingAnchor.constraint(equalTo: super.leadingAnchor, constant: make.space24), + certificateImage.centerYAnchor.constraint(equalTo: super.centerYAnchor), + certificateImage.widthAnchor.constraint(equalToConstant: 28), + certificateImage.heightAnchor.constraint(equalToConstant: 34) + ]) + + NSLayoutConstraint.activate([ + labelStackView.leadingAnchor.constraint(equalTo: certificateImage.trailingAnchor, constant: make.space16), + labelStackView.centerYAnchor.constraint(equalTo: certificateImage.centerYAnchor), + labelStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -make.space16), + labelStackView.heightAnchor.constraint(equalToConstant: 36) + ]) + + + [ + greetingLabel, + certificateLabel + ].forEach({ + self.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + greetingLabel.topAnchor.constraint(equalTo: labelStackView.topAnchor), + greetingLabel.leadingAnchor.constraint(equalTo: labelStackView.leadingAnchor), + greetingLabel.trailingAnchor.constraint(equalTo: labelStackView.trailingAnchor), + greetingLabel.heightAnchor.constraint(equalToConstant: 18) + ]) + + NSLayoutConstraint.activate([ + certificateLabel.bottomAnchor.constraint(equalTo: labelStackView.bottomAnchor, constant: make.space2), + certificateLabel.leadingAnchor.constraint(equalTo: labelStackView.leadingAnchor), + certificateLabel.trailingAnchor.constraint(equalTo: labelStackView.trailingAnchor), + certificateLabel.heightAnchor.constraint(equalToConstant: 22) + ]) + } + + private func setUpStyle() { + self.layer.cornerRadius = 16 + self.layer.borderColor = UIColor.mainRed.cgColor + self.layer.borderWidth = 1 + self.backgroundColor = .mainWhite + } + + func setUpStatus(as status: AngelStatus, leftDay: Int?) { + + let imgName = status.certificationImageName() + certificateImage.image = UIImage(named: imgName) + + var statusString: String + if let leftDayString = leftDay { + statusString = "\(status.certificationStatus()) (D-\(leftDayString))" + } else { + statusString = status.certificationStatus() + } + let certificateStatusString = "Certificate Status: " + let customFont = UIFont(weight: .bold, size: 14) + let attributes: [NSAttributedString.Key: Any] = [ + .font: customFont, + .foregroundColor: UIColor.mainRed + ] + let resultString = NSMutableAttributedString(string: certificateStatusString) + let attributedString = NSMutableAttributedString(string: statusString, attributes: attributes) + resultString.append(attributedString) + + certificateLabel.attributedText = resultString + } + + func setUpGreetingLabel(nickname: String) { + greetingLabel.text = "Hi \(nickname)" + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Main/EducationCollectionViewCell.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Main/EducationCollectionViewCell.swift new file mode 100644 index 0000000..4ef30ce --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Main/EducationCollectionViewCell.swift @@ -0,0 +1,96 @@ +// +// EducationCollectionViewCell.swift +// CPR2U +// +// Created by 황정현 on 2023/03/10. +// + +import UIKit + +final class EducationCollectionViewCell: UICollectionViewCell { + + static let identifier = "EducationCollectionViewCell" + + private lazy var educationNameLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 16) + label.textColor = .mainBlack + return label + }() + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 12) + label.textColor = .mainBlack + return label + }() + private lazy var statusLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 16) + label.textColor = .mainRed + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setUpConstraints() + setUpStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setUpConstraints() { + + let make = Constraints.shared + + [ + educationNameLabel, + descriptionLabel, + statusLabel + ].forEach({ + self.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + educationNameLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: make.space10), + educationNameLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: make.space20), + educationNameLabel.widthAnchor.constraint(equalToConstant: 160), + educationNameLabel.heightAnchor.constraint(equalToConstant: 18), + + ]) + + NSLayoutConstraint.activate([ + descriptionLabel.topAnchor.constraint(equalTo: educationNameLabel.bottomAnchor, constant: make.space2), + descriptionLabel.leadingAnchor.constraint(equalTo: educationNameLabel.leadingAnchor), + educationNameLabel.widthAnchor.constraint(equalToConstant: 160), + educationNameLabel.heightAnchor.constraint(equalToConstant: 18) + ]) + + NSLayoutConstraint.activate([ + statusLabel.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -make.space18), + statusLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -make.space14) + ]) + + } + + private func setUpStyle() { + self.layer.cornerRadius = 20 + } + + func setUpEducationNameLabel(as str: String) { + educationNameLabel.text = str + } + + func setUpDescriptionLabel(as str: String) { + descriptionLabel.text = str + } + + func setUpStatus(isCompleted: Bool) { + statusLabel.text = isCompleted == true ? "Completed" : "Not Completed" + statusLabel.textColor = isCompleted == true ? .mainRed : .mainDarkGray + self.backgroundColor = isCompleted == true ? .mainLightRed : .mainLightGray + } + +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Main/EducationMainViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Main/EducationMainViewController.swift new file mode 100644 index 0000000..6835a4c --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Main/EducationMainViewController.swift @@ -0,0 +1,225 @@ +// +// EducationMainViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/09. +// + +import Combine +import UIKit + +protocol EducationMainViewControllerDelegate: AnyObject { + func updateUserEducationStatus() +} + +final class EducationMainViewController: UIViewController { + + private var certificateStatusView = CertificateStatusView() + private let progressView = EducationProgressView() + let educationCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + + private let viewModel: EducationViewModel + private var cancellables = Set() + + private weak var delegate: EducationMainViewControllerDelegate? + + private lazy var noticeView = CustomNoticeView(noticeAs: .certificate) + private lazy var addressSettingView = AddressSettingView() + + init(viewModel: EducationViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setUpConstraints() + setUpStyle() + bind(to: viewModel) + noticeView.setCertificateNotice() + } + + private func setUpConstraints() { + let safeArea = view.safeAreaLayoutGuide + let make = Constraints.shared + [ + certificateStatusView, + progressView, + educationCollectionView, + addressSettingView, + noticeView + ].forEach({ + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + certificateStatusView.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: make.space8), + certificateStatusView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: make.space16), + certificateStatusView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -make.space16), + certificateStatusView.heightAnchor.constraint(equalToConstant: 64) + ]) + + NSLayoutConstraint.activate([ + progressView.topAnchor.constraint(equalTo: certificateStatusView.bottomAnchor, constant: make.space6), + progressView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: make.space16), + progressView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -make.space16), + progressView.heightAnchor.constraint(equalToConstant: 40) + ]) + + NSLayoutConstraint.activate([ + educationCollectionView.topAnchor.constraint(equalTo: progressView.bottomAnchor), + educationCollectionView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor), + educationCollectionView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor), + educationCollectionView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor) + ]) + + NSLayoutConstraint.activate([ + addressSettingView.topAnchor.constraint(equalTo: view.topAnchor), + addressSettingView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + addressSettingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + addressSettingView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + NSLayoutConstraint.activate([ + noticeView.topAnchor.constraint(equalTo: view.topAnchor), + noticeView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + noticeView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + noticeView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + private func setUpStyle() { + guard let navBar = self.navigationController?.navigationBar else { return } + navBar.prefersLargeTitles = true + navBar.topItem?.title = "Education" + navBar.largeTitleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.mainRed] + self.navigationController?.navigationBar.prefersLargeTitles = true + } + + private func setUpCollectionView() { + educationCollectionView.dataSource = self + educationCollectionView.delegate = self + educationCollectionView.register(EducationCollectionViewCell.self, forCellWithReuseIdentifier: EducationCollectionViewCell.identifier) + } + + private func bind(to viewModel: EducationViewModel) { + Task { + let output = try await viewModel.transform() + + output.certificateStatus?.sink { status in + self.certificateStatusView.setUpStatus(as: status.status, leftDay: status.leftDay) + if status.status == .acquired{ + if UserDefaultsManager.isCertificateNotice == false { + self.noticeView.noticeAppear() + UserDefaultsManager.isCertificateNotice = true + } + if UserDefaultsManager.isAddressSet == false { + self.addressSettingView.noticeAppear() + UserDefaultsManager.isAddressSet = true + } + } + }.store(in: &cancellables) + + output.nickname?.sink { nickname in + self.certificateStatusView.setUpGreetingLabel(nickname: nickname) + }.store(in: &cancellables) + + output.progressPercent?.sink { progress in + self.progressView.setUpProgress(as: progress) + }.store(in: &cancellables) + + DispatchQueue.main.async { + self.setUpCollectionView() + self.educationCollectionView.reloadData() + } + } + } +} + +extension EducationMainViewController: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return viewModel.educationName().count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "EducationCollectionViewCell", for: indexPath) as! EducationCollectionViewCell + + cell.setUpEducationNameLabel(as: viewModel.educationName()[indexPath.row]) + cell.setUpDescriptionLabel(as: viewModel.educationDescription()[indexPath.row]) + cell.setUpStatus(isCompleted: viewModel.educationStatus()[indexPath.row].value) + + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let index = indexPath.row + let isCompleted = index != 0 ? viewModel.educationStatus()[index - 1].value : true + if isCompleted == true { + navigateTo(index: index) + } else { + view.showToastMessage(type: .education) + } + } +} + +extension EducationMainViewController: UICollectionViewDelegate { + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return 358 + } +} + +extension EducationMainViewController: UICollectionViewDelegateFlowLayout { + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + minimumLineSpacingForSectionAt section: Int + ) -> CGFloat { + return Constraints.shared.space16 + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + return CGSize(width: 358, height: 108) + } + + func navigateTo(index: Int) { + var vc: UIViewController + if index == 0 { + vc = LectureViewController(viewModel: viewModel) + navigationController?.pushViewController(vc, animated: true) + } else if index == 1 { + let temp = EducationQuizViewController() + temp.delegate = self + vc = UINavigationController(rootViewController: temp) + vc.modalPresentationStyle = .overFullScreen + self.present(vc, animated: true) + } else { + vc = PracticeExplainViewController(viewModel: viewModel) + navigationController?.pushViewController(vc, animated: true) + } + } +} + +extension EducationMainViewController: EducationMainViewControllerDelegate { + func updateUserEducationStatus() { + Task { + let userInfo = try await viewModel.receiveEducationStatus() + viewModel.updateInput(data: userInfo) + + DispatchQueue.main.async { [weak self] in + self?.educationCollectionView.reloadData() + self?.dismiss(animated: true) + } + } + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Main/EducationProgressView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Main/EducationProgressView.swift new file mode 100644 index 0000000..73b9eaa --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Main/EducationProgressView.swift @@ -0,0 +1,89 @@ +// +// EducationProgressView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/09. +// + +import UIKit + +final class EducationProgressView: UIView { + + private let annotationLabel: UILabel = { + let label = UILabel() + let color = UIColor(rgb: 0x767676) + label.font = UIFont(weight: .regular, size: 11) + label.textColor = color + label.text = "CPR Angel Certification Progress" + return label + }() + + private let infoButton: UIButton = { + let button = UIButton() + let color = UIColor(rgb: 0x767676) + let config = UIImage.SymbolConfiguration(pointSize: 10, weight: .light, scale: .medium) + guard let img = UIImage(systemName: "info.circle", withConfiguration: config)?.withTintColor(color).withRenderingMode(.alwaysOriginal) else { return UIButton() } + button.setImage(img, for: .normal) + return button + }() + + private lazy var progressView: UIProgressView = { + let view = UIProgressView() + view.trackTintColor = .mainLightRed.withAlphaComponent(0.05) + view.progressTintColor = .mainRed + view.layer.borderColor = UIColor.mainRed.cgColor + view.layer.borderWidth = 1 + view.layer.cornerRadius = 8 + view.layer.sublayers![1].cornerRadius = 8 + view.clipsToBounds = true + return view + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + setUpConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setUpConstraints() { + let make = Constraints.shared + + [ + annotationLabel, + infoButton, + progressView + ].forEach({ + self.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + annotationLabel.topAnchor.constraint(equalTo: self.topAnchor), + annotationLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: make.space6), + annotationLabel.widthAnchor.constraint(equalToConstant: 168), + annotationLabel.heightAnchor.constraint(equalToConstant: 14) + ]) + + NSLayoutConstraint.activate([ + infoButton.leadingAnchor.constraint(equalTo: annotationLabel.trailingAnchor, constant: make.space4), + infoButton.centerYAnchor.constraint(equalTo: annotationLabel.centerYAnchor), + infoButton.widthAnchor.constraint(equalToConstant: 10), + infoButton.heightAnchor.constraint(equalToConstant: 10) + ]) + + NSLayoutConstraint.activate([ + progressView.topAnchor.constraint(equalTo: annotationLabel.bottomAnchor, constant: make.space2), + progressView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: make.space6), + progressView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + progressView.heightAnchor.constraint(equalToConstant: 16) + ]) + } + + func setUpProgress(as value: Float) { + progressView.progress = value + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Pose/CameraOverlayView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Pose/CameraOverlayView.swift new file mode 100644 index 0000000..fe87408 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Pose/CameraOverlayView.swift @@ -0,0 +1,237 @@ +// Copyright 2021 The TensorFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ============================================================================= +// +// CameraOverlayView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/19. +// + +import UIKit +import os + +/// Custom view to visualize the pose estimation result on top of the input image. +class CameraOverlayView: UIImageView { + + private var maxHeight: CGFloat = 0 + private var minHeight: CGFloat = 0 + private var beforeWrist: CGFloat = 0 + private var increased: Bool = true + private var wristList: [CGFloat] = [] + + var correct = 0 + var nonCorrect = 0 + + required init() { + super.init(frame: CGRect.zero) + + self.contentMode = .scaleAspectFill + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Visualization configs + private enum Config { + static let dot = (radius: CGFloat(5), color: UIColor.orange) + } + + /// List of lines connecting each part to be visualized. + private static let lines = [ + (from: BodyPart.leftWrist, to: BodyPart.leftElbow), + (from: BodyPart.leftElbow, to: BodyPart.leftShoulder), + (from: BodyPart.leftShoulder, to: BodyPart.rightShoulder), + (from: BodyPart.rightShoulder, to: BodyPart.rightElbow), + (from: BodyPart.rightElbow, to: BodyPart.rightWrist), + (from: BodyPart.leftShoulder, to: BodyPart.leftHip), + (from: BodyPart.leftHip, to: BodyPart.rightHip), + (from: BodyPart.rightHip, to: BodyPart.rightShoulder), + (from: BodyPart.leftHip, to: BodyPart.leftKnee), + (from: BodyPart.leftKnee, to: BodyPart.leftAnkle), + (from: BodyPart.rightHip, to: BodyPart.rightKnee), + (from: BodyPart.rightKnee, to: BodyPart.rightAnkle), + ] + + /// CGContext to draw the detection result. + var context: CGContext! + + /// Draw the detected keypoints on top of the input image. + /// + /// - Parameters: + /// - image: The input image. + /// - person: Keypoints of the person detected (i.e. output of a pose estimation model) + func draw(at image: UIImage, person: Person) { + if context == nil { + UIGraphicsBeginImageContext(image.size) + guard let context = UIGraphicsGetCurrentContext() else { + fatalError("set current context faild") + } + self.context = context + } + guard let strokes = strokes(from: person) else { return } + + measureCprScore(person: person) + + image.draw(at: .zero) + context.setLineWidth(Config.dot.radius) + context.setStrokeColor(UIColor.blue.cgColor) + context.strokePath() + guard let newImage = UIGraphicsGetImageFromCurrentImageContext() else { fatalError() } + self.image = newImage + } + + /// Draw the dots (i.e. keypoints). + /// + /// - Parameters: + /// - context: The context to be drawn on. + /// - dots: The list of dots to be drawn. + private func drawDots(at context: CGContext, dots: [CGPoint]) { + for dot in dots { + let dotRect = CGRect( + x: dot.x - Config.dot.radius / 2, y: dot.y - Config.dot.radius / 2, + width: Config.dot.radius, height: Config.dot.radius) + let path = CGPath( + roundedRect: dotRect, cornerWidth: Config.dot.radius, cornerHeight: Config.dot.radius, + transform: nil) + context.addPath(path) + } + } + + /// Generate a list of strokes to draw in order to visualize the pose estimation result. + /// + /// - Parameters: + /// - person: The detected person (i.e. output of a pose estimation model). + private func strokes(from person: Person) -> Strokes? { + var strokes = Strokes(dots: [], lines: []) + // MARK: Visualization of detection result + var bodyPartToDotMap: [BodyPart: CGPoint] = [:] + for (index, part) in BodyPart.allCases.enumerated() { + if part == .rightAnkle { + + } + let position = CGPoint( + x: person.keyPoints[index].coordinate.x, + y: person.keyPoints[index].coordinate.y) + bodyPartToDotMap[part] = position + strokes.dots.append(position) + } + + do { + try strokes.lines = CameraOverlayView.lines.map { map throws -> Line in + guard let from = bodyPartToDotMap[map.from] else { + throw VisualizationError.missingBodyPart(of: map.from) + } + guard let to = bodyPartToDotMap[map.to] else { + throw VisualizationError.missingBodyPart(of: map.to) + } + return Line(from: from, to: to) + } + } catch VisualizationError.missingBodyPart(let missingPart) { + os_log("Visualization error: %s is missing.", type: .error, missingPart.rawValue) + return nil + } catch { + os_log("Visualization error: %s", type: .error, error.localizedDescription) + return nil + } + return strokes + } + + private func measureCprScore(person: Person) { + var xShoulder: CGFloat = 0 + var yShoulder: CGFloat = 0 + var xElbow: CGFloat = 0 + var yElbow: CGFloat = 0 + var xWrist: CGFloat = 0 + var yWrist: CGFloat = 0 + + // person이 갖고 있는 관절 데이터들에서 어깨, 팔꿈치, 손목 데이터 추출 (현재 임시로 왼쪽 관절만 추출한 상태) + person.keyPoints.forEach( { point in + if point.bodyPart == .leftShoulder { + xShoulder = point.coordinate.x + yShoulder = point.coordinate.y + } else if point.bodyPart == .leftElbow { + xElbow = point.coordinate.x + yElbow = point.coordinate.y + } else if point.bodyPart == .leftWrist { + xWrist = point.coordinate.x + yWrist = point.coordinate.y + } + }) + + // 일직선 판별 + var isCorrect = xShoulder - xElbow < 10 && xElbow - xWrist < 10 + if (isCorrect) { + correct += 1 + } else { + nonCorrect += 1 + } + + // 손목의 높이가 상승 곡선에서 꼭짓점을 찍고 하강하는 경우 + if (increased && beforeWrist > yWrist + 1) { + increased = false + maxHeight = yWrist + } + // 손목의 높이가 하강 곡선에서 꼭짓점을 찍고 상승하는 경우 + else if (!increased && beforeWrist < yWrist - 1) { + increased = true + minHeight = yWrist + + // wristList에 ${손목의 최대 높이 - 손목의 최소 높이}를 저장 + + let num = maxHeight > minHeight ? maxHeight - minHeight : minHeight - maxHeight + wristList.append(num) + print(wristList.last) + + // wristList에 저장된 깊이 값으로 CPR 깊이가 적절한지 확인한다. + // wristList에 저장된 값의 개수로 CPR 속도(2분 동안 CPR한 횟수)가 적절한지 확인한다. + // 가슴압박 속도는 분당 100~120회, 깊이는 5~6㎝로 빠르고 깊게 30회 압박 + // 2분 -> 200~240회 : 추후 1분당 평균 내는것도 나쁘지 않을듯 + } + + beforeWrist = yWrist + } + + func getCompressionTotalCount() -> Int { + return wristList.count + } + + func getArmAngleRate() -> (correct: Int, nonCorrect: Int) { + return (correct, nonCorrect) + } + + func getAveragePressDepth() -> CGFloat { + let total = wristList.reduce(0){$0 + $1} + let len = CGFloat(wristList.count) + return total/len + } + +} + +/// The strokes to be drawn in order to visualize a pose estimation result. +fileprivate struct Strokes { + var dots: [CGPoint] + var lines: [Line] +} + +/// A straight line. +fileprivate struct Line { + let from: CGPoint + let to: CGPoint +} + +fileprivate enum VisualizationError: Error { + case missingBodyPart(of: BodyPart) +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Pose/EvaluationResultView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Pose/EvaluationResultView.swift new file mode 100644 index 0000000..d2eff42 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Pose/EvaluationResultView.swift @@ -0,0 +1,111 @@ +// +// EvaluationResultView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/10. +// + +import UIKit + +final class EvaluationResultView: UIView { + + private let evaluationTargetImageView = UIImageView() + private let titleLabel = UILabel() + private let resultLabel = UILabel() + private let descriptionLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + setUpConstraints() + setUpStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setUpConstraints() { + let make = Constraints.shared + + [ + evaluationTargetImageView, + titleLabel, + resultLabel, + descriptionLabel + ].forEach({ + self.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + evaluationTargetImageView.topAnchor.constraint(equalTo: self.topAnchor, constant: make.space12), + evaluationTargetImageView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: make.space16), + evaluationTargetImageView.widthAnchor.constraint(equalToConstant: 28), + evaluationTargetImageView.heightAnchor.constraint(equalToConstant: 28) + ]) + + NSLayoutConstraint.activate([ + titleLabel.leadingAnchor.constraint(equalTo: evaluationTargetImageView.trailingAnchor, constant: make.space4), + titleLabel.centerYAnchor.constraint(equalTo: evaluationTargetImageView.centerYAnchor), + titleLabel.widthAnchor.constraint(equalToConstant: 180), + titleLabel.heightAnchor.constraint(equalToConstant: 35) + ]) + + NSLayoutConstraint.activate([ + resultLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: make.space24), + resultLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + resultLabel.widthAnchor.constraint(equalToConstant: 145), + resultLabel.heightAnchor.constraint(equalToConstant: 24) + ]) + + NSLayoutConstraint.activate([ + descriptionLabel.topAnchor.constraint(equalTo: resultLabel.bottomAnchor), + descriptionLabel.leadingAnchor.constraint(equalTo: resultLabel.leadingAnchor), + descriptionLabel.widthAnchor.constraint(equalToConstant: 180), + descriptionLabel.heightAnchor.constraint(equalToConstant: 24) + ]) + } + + private func setUpStyle() { + self.layer.cornerRadius = 16 + self.layer.borderColor = UIColor.mainRed.cgColor + self.layer.borderWidth = 1 + self.backgroundColor = .mainLightRed.withAlphaComponent(0.05) + + [ + titleLabel, resultLabel, descriptionLabel + ].forEach({ + $0.textColor = .mainBlack + $0.textAlignment = .left + }) + titleLabel.font = UIFont(weight: .bold, size: 20) + resultLabel.font = UIFont(weight: .bold, size: 14) + descriptionLabel.font = UIFont(weight: .regular, size: 14) + + titleLabel.adjustsFontSizeToFitWidth = true + titleLabel.minimumScaleFactor = 0.5 + + descriptionLabel.adjustsFontSizeToFitWidth = true + descriptionLabel.minimumScaleFactor = 0.5 + descriptionLabel.numberOfLines = 3 + } + + func setImage(imgName systemName: String) { + let config = UIImage.SymbolConfiguration(pointSize: 28, weight: .regular, scale: .medium) + evaluationTargetImageView.image = UIImage(systemName: systemName, withConfiguration: config) + + } + + func setTitle(title: String) { + titleLabel.text = title + } + + func setResultLabelText(as text: String) { + resultLabel.text = text + } + + func setDescriptionLabelText(as text: String) { + descriptionLabel.text = text + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Pose/PosePracticeResultViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Pose/PosePracticeResultViewController.swift new file mode 100644 index 0000000..4206672 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Pose/PosePracticeResultViewController.swift @@ -0,0 +1,169 @@ +// +// PosePracticeResultViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/10. +// + +import Combine +import CombineCocoa +import UIKit + +final class PosePracticeResultViewController: UIViewController { + private let compressRateResultView: EvaluationResultView = { + let view = EvaluationResultView() + view.setImage(imgName: "ruler") + view.setTitle(title: "Compression Rate") + view.setResultLabelText(as: "0.5 per 1 time") + view.setDescriptionLabelText(as: "It’s too fast. Little bit Slower") + return view + }() + + private let pressDepthResultView: EvaluationResultView = { + let view = EvaluationResultView() + view.setImage(imgName: "ruler") + view.setTitle(title: "Press Depth") + view.setResultLabelText(as: "Slightly shallow") + view.setDescriptionLabelText(as: "Press little deeper") + return view + }() + + private let handLocationResultView: EvaluationResultView = { + let view = EvaluationResultView() + view.setImage(imgName: "ruler") + view.setTitle(title: "Hand Location") + view.setResultLabelText(as: "Adequate") + view.setDescriptionLabelText(as: "Nice Location!") + return view + }() + + private let armAngleResultView: EvaluationResultView = { + let view = EvaluationResultView() + view.setImage(imgName: "ruler") + view.setTitle(title: "Arm Angle") + view.setResultLabelText(as: "Adequate") + view.setDescriptionLabelText(as: "Nice Angle!") + return view + }() + + private let finalResultView = ScoreResultView() + + private let quitButton: UIButton = { + let button = UIButton() + button.backgroundColor = .mainRed + button.layer.cornerRadius = 19 + button.titleLabel?.font = UIFont(weight: .bold, size: 17) + button.setTitleColor(.mainWhite, for: .normal) + button.setTitle("QUIT", for: .normal) + return button + }() + + private let viewModel: EducationViewModel + private var cancellables = Set() + + private var score: Int = 0 + + init(viewModel: EducationViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setUpConstraints() + setUpStyle() + bind(viewModel: viewModel) + setUpText() + } + + private func setUpConstraints() { + let safeArea = view.safeAreaLayoutGuide + let make = Constraints.shared + + [ + compressRateResultView, + pressDepthResultView, + handLocationResultView, + armAngleResultView, + finalResultView, + quitButton + ].forEach({ + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + let evaluationResultViewArr = [compressRateResultView, pressDepthResultView, handLocationResultView, armAngleResultView,] + + evaluationResultViewArr.forEach({ + $0.widthAnchor.constraint(equalToConstant: 255).isActive = true + $0.heightAnchor.constraint(equalToConstant: 150).isActive = true + }) + + NSLayoutConstraint.activate([ + compressRateResultView.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: make.space24), + compressRateResultView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: make.space16) + ]) + + NSLayoutConstraint.activate([ + pressDepthResultView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -make.space24), + pressDepthResultView.leadingAnchor.constraint(equalTo: compressRateResultView.leadingAnchor), + ]) + + NSLayoutConstraint.activate([ + handLocationResultView.topAnchor.constraint(equalTo: compressRateResultView.topAnchor), + handLocationResultView.leadingAnchor.constraint(equalTo: compressRateResultView.trailingAnchor, constant: make.space16) + ]) + + NSLayoutConstraint.activate([ + armAngleResultView.bottomAnchor.constraint(equalTo: pressDepthResultView.bottomAnchor), + armAngleResultView.leadingAnchor.constraint(equalTo: compressRateResultView.trailingAnchor, constant: make.space16) + ]) + + NSLayoutConstraint.activate([ + quitButton.bottomAnchor.constraint(equalTo: armAngleResultView.bottomAnchor), + quitButton.leadingAnchor.constraint(equalTo: armAngleResultView.trailingAnchor, constant: make.space16), + quitButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -make.space16), + quitButton.heightAnchor.constraint(equalToConstant: 45) + ]) + + NSLayoutConstraint.activate([ + finalResultView.topAnchor.constraint(equalTo: handLocationResultView.topAnchor), + finalResultView.bottomAnchor.constraint(equalTo: quitButton.topAnchor, constant: -make.space12), + finalResultView.leadingAnchor.constraint(equalTo: quitButton.leadingAnchor), + finalResultView.trailingAnchor.constraint(equalTo: quitButton.trailingAnchor) + + ]) + } + + private func setUpStyle() { + view.backgroundColor = .white + } + + private func bind(viewModel: EducationViewModel) { + quitButton.tapPublisher.sink { [weak self] _ in + self?.setUpOrientation(as: .portrait) + Task { + try await viewModel.savePosturePracticeResult(score: self?.score ?? 0) + let rootVC = TabBarViewController(0) + await self?.view.window?.setRootViewController(rootVC) + } + }.store(in: &cancellables) + } + + private func setUpText() { + let result = viewModel.judgePostureResult() + compressRateResultView.setResultLabelText(as: result.compResult.rawValue) + compressRateResultView.setDescriptionLabelText(as: result.compResult.description) + armAngleResultView.setResultLabelText(as: result.angleResult.rawValue) + armAngleResultView.setDescriptionLabelText(as: result.angleResult.description) + pressDepthResultView.setResultLabelText(as: result.pressDepth.rawValue) + pressDepthResultView.setDescriptionLabelText(as: result.pressDepth.description) + score = result.compResult.score + result.angleResult.score + result.pressDepth.score + 1 + finalResultView.setUpScore(score: score) + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Pose/PosePracticeViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Pose/PosePracticeViewController.swift new file mode 100644 index 0000000..ca65cc6 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Pose/PosePracticeViewController.swift @@ -0,0 +1,293 @@ +// +// PosePracticeViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/10. +// + +import AVFoundation +import CombineCocoa +import Combine +import os +import UIKit + +enum Constants { + // Configs for the TFLite interpreter. + static let defaultThreadCount = 4 + static let defaultDelegate: Delegates = .gpu + static let defaultModelType: ModelType = .movenetThunder + + // Minimum score to render the result. + static let minimumScore: Float32 = 0.2 +} + +final class PosePracticeViewController: UIViewController { + + private let timeImageView: UIImageView = { + let view = UIImageView() + let config = UIImage.SymbolConfiguration(pointSize: 26, weight: .regular, scale: .medium) + view.image = UIImage(systemName: "clock", withConfiguration: config) + return view + }() + + private lazy var timeLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 24) + label.textColor = .mainBlack + label.text = "02:00" + return label + }() + + private let soundImageView: UIImageView = { + let view = UIImageView() + let config = UIImage.SymbolConfiguration(pointSize: 29, weight: .regular, scale: .medium) + view.image = UIImage(systemName: "metronome", withConfiguration: config) + return view + }() + private let soundSwitch: UISwitch = { + let sSwitch = UISwitch() + sSwitch.onTintColor = .mainRed + sSwitch.isOn = true + return sSwitch + }() + + private let quitButton: UIButton = { + let button = UIButton() + button.backgroundColor = .mainRed + button.layer.cornerRadius = 19 + button.titleLabel?.font = UIFont(weight: .bold, size: 17) + button.setTitleColor(.mainWhite, for: .normal) + button.setTitle("QUIT", for: .normal) + return button + }() + + private lazy var overlayView = CameraOverlayView() + + // MARK: Pose estimation model configs + private var modelType: ModelType = Constants.defaultModelType + private var threadCount: Int = Constants.defaultThreadCount + private var delegate: Delegates = Constants.defaultDelegate + private let minimumScore = Constants.minimumScore + + // MARK: Visualization + private var imageViewFrame: CGRect? + + // MARK: Controllers that manage functionality + private var poseEstimator: PoseEstimator? + private var cameraFeedManager: CameraFeedManager! + + private let queue = DispatchQueue(label: "serial_queue") + private var isRunning = false + + private let viewModel: EducationViewModel + private var cancellables = Set() + private var audioPlayer: AVAudioPlayer! + + init(viewModel: EducationViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + viewModel.updateTimerType(vc: self) + setUpOrientation(as: .landscape) + setUpConstraints() + updateModel() + configCameraCapture() + setTimer() + playSound() + setUpAction() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + cameraFeedManager?.startRunning() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + cameraFeedManager?.stopRunning() + viewModel.timer.connect().cancel() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + imageViewFrame = overlayView.frame + } + + + + private func setUpConstraints() { + let safeArea = view.safeAreaLayoutGuide + let make = Constraints.shared + + [ + overlayView, + timeImageView, + timeLabel, + soundImageView, + soundSwitch, + quitButton + ].forEach({ + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + timeImageView.topAnchor.constraint(equalTo: view.topAnchor, constant: make.space16), + timeImageView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: make.space16), + timeImageView.widthAnchor.constraint(equalToConstant: 26), + timeImageView.heightAnchor.constraint(equalToConstant: 26) + ]) + + NSLayoutConstraint.activate([ + timeLabel.leadingAnchor.constraint(equalTo: timeImageView.trailingAnchor, constant: make.space24), + timeLabel.centerYAnchor.constraint(equalTo: timeImageView.centerYAnchor), + timeLabel.widthAnchor.constraint(equalToConstant: 64), + timeLabel.heightAnchor.constraint(equalToConstant: 32) + ]) + + NSLayoutConstraint.activate([ + soundImageView.topAnchor.constraint(equalTo: timeImageView.bottomAnchor, constant: make.space16), + soundImageView.leadingAnchor.constraint(equalTo: timeImageView.leadingAnchor), + soundImageView.widthAnchor.constraint(equalToConstant: 30), + soundImageView.heightAnchor.constraint(equalToConstant: 30) + ]) + + NSLayoutConstraint.activate([ + soundSwitch.leadingAnchor.constraint(equalTo: soundImageView.trailingAnchor, constant: make.space24), + soundSwitch.centerYAnchor.constraint(equalTo: soundImageView.centerYAnchor), + soundSwitch.widthAnchor.constraint(equalToConstant: 30), + soundSwitch.heightAnchor.constraint(equalToConstant: 30) + ]) + + NSLayoutConstraint.activate([ + quitButton.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -make.space4), + quitButton.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -make.space4), + quitButton.widthAnchor.constraint(equalToConstant: 160), + quitButton.heightAnchor.constraint(equalToConstant: 38) + ]) + + NSLayoutConstraint.activate([ + overlayView.topAnchor.constraint(equalTo: view.topAnchor), + overlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + overlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + overlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + private func configCameraCapture() { + cameraFeedManager = CameraFeedManager() + cameraFeedManager.startRunning() + cameraFeedManager.delegate = self + } + + private func updateModel() { + queue.async { + do { + self.poseEstimator = try MoveNet( + threadCount: self.threadCount, + delegate: self.delegate, + modelType: self.modelType) + } catch let error { + os_log("Error: %@", log: .default, type: .error, String(describing: error)) + } + } + } + + private func setTimer() { + let count = viewModel.timeLimit() + viewModel.timer = Timer.publish(every: 1, on: .current, in: .common) + viewModel.timer + .autoconnect() + .scan(0) { counter, _ in counter + 1 } + .sink { [self] counter in + if counter > 5 { + timeLabel.text = (count - counter - 5).numberAsTime() + if counter == count - 5 { + cameraFeedManager.stopRunning() + viewModel.setPostureResult(compCount: overlayView.getCompressionTotalCount(), armAngleCount: overlayView.getArmAngleRate(), pressDepth: overlayView.getAveragePressDepth()) + Task { + usleep(1000000) + audioPlayer.stop() + let vc = PosePracticeResultViewController(viewModel: viewModel) + vc.modalPresentationStyle = .overFullScreen + self.present(vc, animated: true) + } + viewModel.timer.connect().cancel() + } + } + }.store(in: &cancellables) + } + + private func setUpAction() { + soundSwitch.isOnPublisher.sink { isOn in + self.audioPlayer.volume = isOn ? 1 : 0 + }.store(in: &cancellables) + + quitButton.tapPublisher.sink { [weak self] in + self?.audioPlayer.stop() + self?.setUpOrientation(as: .portrait) + self?.dismiss(animated: true) + }.store(in: &cancellables) + } + + private func playSound() { + guard let url = Bundle.main.url(forResource: "CPR_Posture_Sound", withExtension: "mp3") else { return } + do { + audioPlayer = try AVAudioPlayer(contentsOf: url) + } catch (let error) { + print(error) + } + audioPlayer?.play() + } + +} + +// MARK: - CameraFeedManagerDelegate Methods +extension PosePracticeViewController: CameraFeedManagerDelegate { + func cameraFeedManager( + _ cameraFeedManager: CameraFeedManager, didOutput pixelBuffer: CVPixelBuffer + ) { + self.runModel(pixelBuffer) + } + + private func runModel(_ pixelBuffer: CVPixelBuffer) { + // Guard to make sure that there's only 1 frame process at each moment. + guard !isRunning else { return } + + guard let estimator = poseEstimator else { return } + + queue.async { + self.isRunning = true + defer { self.isRunning = false } + + do { + let (result, _) = try estimator.estimateSinglePose( + on: pixelBuffer) + + DispatchQueue.main.async { + let image = UIImage(ciImage: CIImage(cvPixelBuffer: pixelBuffer)) + if result.score < self.minimumScore { + self.overlayView.image = image + return + } + + self.overlayView.draw(at: image, person: result) + } + } catch { + os_log("Error running pose estimation.", type: .error) + return + } + } + } + + private func setUpText() { + _ = viewModel.judgePostureResult() + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Pose/PracticeExplainViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Pose/PracticeExplainViewController.swift new file mode 100644 index 0000000..cd547d0 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Pose/PracticeExplainViewController.swift @@ -0,0 +1,184 @@ +// +// PracticeExplainViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/10. +// + +import Combine +import CombineCocoa +import UIKit + +final class PracticeExplainViewController: UIViewController { + + // temp: 추후 설명용 이미지가 삽입될 영역 + private let imageList: [String] = ["onboarding1.png", "onboarding2.png", "onboarding3.png", "onboarding4.png"] + private let titleList: [String] = ["Prepare tools","Prepare tools", "Draw an angry man", "Ready"] + private let descriptionList: [String] = ["If you do not have a CPR mannequin,\nplease prepare a plastic bottle, pillow, etc.", "Put the plastic bottle inside the clothes\nyou don't wear and wrap it up.", "Draw an angry man on your clothes or pillow\nusing tape or pen.", "Please press the location marked in red!"] + + private lazy var pageControl: UIPageControl = { + let pageControl = UIPageControl() + pageControl.numberOfPages = imageList.count + pageControl.currentPage = 0 + pageControl.pageIndicatorTintColor = .mainLightGray + pageControl.currentPageIndicatorTintColor = .black + return pageControl + }() + + private lazy var onboardingScrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.frame = CGRect(x: 0, y: 0, width: 390, height: 300) + scrollView.isPagingEnabled = true + scrollView.showsHorizontalScrollIndicator = false + return scrollView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 18) + label.textColor = .mainBlack + label.textAlignment = .center + return label + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 14) + label.textColor = .mainBlack + label.textAlignment = .center + label.numberOfLines = 2 + return label + }() + + private let moveButton: UIButton = { + let button = UIButton() + button.backgroundColor = .mainLightRed + button.setTitleColor(.mainBlack, for: .normal) + button.titleLabel?.font = UIFont(weight: .bold, size: 16) + button.setTitle("Moving on to Posture Practice", for: .normal) + return button + }() + + private let viewModel: EducationViewModel + private var cancellables = Set() + + init(viewModel: EducationViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setUpConstraints() + setUpStyle() + setUpAction() + updateOnboardingComponent(index: 0) + setUpDelegate() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(true) + navigationController?.navigationBar.prefersLargeTitles = false + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(true) + navigationController?.navigationBar.prefersLargeTitles = true + viewModel.timer.connect().cancel() + } + + private func setUpConstraints() { + let safeArea = view.safeAreaLayoutGuide + + [ + onboardingScrollView, + titleLabel, + descriptionLabel, + pageControl, + moveButton + ].forEach({ + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + moveButton.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor), + moveButton.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor), + moveButton.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor), + moveButton.heightAnchor.constraint(equalToConstant: 80) + ]) + + NSLayoutConstraint.activate([ + pageControl.bottomAnchor.constraint(equalTo: moveButton.topAnchor, constant: -62), + pageControl.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor), + pageControl.widthAnchor.constraint(equalTo: safeArea.widthAnchor), + pageControl.heightAnchor.constraint(equalToConstant: 12) + ]) + + NSLayoutConstraint.activate([ + descriptionLabel.bottomAnchor.constraint(equalTo: pageControl.topAnchor, constant: -40), + descriptionLabel.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor), + descriptionLabel.widthAnchor.constraint(equalTo: safeArea.widthAnchor), + descriptionLabel.heightAnchor.constraint(equalToConstant: 48) + ]) + + NSLayoutConstraint.activate([ + titleLabel.bottomAnchor.constraint(equalTo: descriptionLabel.topAnchor, constant: -18), + titleLabel.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor), + titleLabel.widthAnchor.constraint(equalTo: safeArea.widthAnchor), + titleLabel.heightAnchor.constraint(equalToConstant: 24) + ]) + + NSLayoutConstraint.activate([ + onboardingScrollView.bottomAnchor.constraint(equalTo: titleLabel.topAnchor, constant: -30), + onboardingScrollView.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor), + onboardingScrollView.widthAnchor.constraint(equalToConstant: 390), + onboardingScrollView.heightAnchor.constraint(equalToConstant: 300) + ]) + + for i in 0..= 80 { + descriptionLabel.text = "PASSED!" + } else { + descriptionLabel.text = "FAILED..." + } + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Quiz/CustomNoticeView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Quiz/CustomNoticeView.swift new file mode 100644 index 0000000..928975e --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Quiz/CustomNoticeView.swift @@ -0,0 +1,211 @@ +// +// CustomNoticeView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/10. +// + +import UIKit + +protocol CustomNoticeViewDelegate: AnyObject { + func dismissQuizViewController() +} + +enum NoticeUsage { + case pf + case certificate +} + +class CustomNoticeView: UIView { + + weak var delegate: CustomNoticeViewDelegate? + + private lazy var shadowView: UIView = { + let view = UIView() + + view.backgroundColor = UIColor(rgb: 0x7B7B7B).withAlphaComponent(0.45) + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: view.topAnchor), + view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + return view + } () + + private let noticeView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(rgb: 0xFCFCFC) + view.layer.cornerRadius = 20 + return view + }() + + private let thumbnailImageView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFit + return view + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 18) + label.textAlignment = .center + label.textColor = .mainBlack + return label + }() + private let subTitleLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 14) + label.textAlignment = .center + label.textColor = .mainBlack + return label + }() + + let confirmButton: UIButton = { + let button = UIButton() + button.layer.cornerRadius = 22 + button.backgroundColor = .mainRed + button.titleLabel?.font = UIFont(weight: .bold, size: 17) + button.setTitleColor(.mainWhite, for: .normal) + button.setTitle("CONFIRM", for: .normal) + return button + }() + private let appearAnimDuration: CGFloat = 0.4 + + private var noticeType = NoticeUsage.pf + + init(noticeAs: NoticeUsage) { + super.init(frame: CGRect.zero) + + setUpConstraints() + setUpStyle() + setUpComponent() + noticeType = noticeAs + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setUpConstraints() { + let make = Constraints.shared + + self.addSubview(noticeView) + noticeView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + noticeView.centerXAnchor.constraint(equalTo: self.centerXAnchor), + noticeView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + noticeView.widthAnchor.constraint(equalToConstant: 313), + noticeView.heightAnchor.constraint(equalToConstant: 308) + ]) + + [ + thumbnailImageView, + titleLabel, + subTitleLabel, + confirmButton + ].forEach({ + noticeView.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + thumbnailImageView.topAnchor.constraint(equalTo: noticeView.topAnchor, constant: 40), + thumbnailImageView.leadingAnchor.constraint(equalTo: noticeView.leadingAnchor), + thumbnailImageView.trailingAnchor.constraint(equalTo: noticeView.trailingAnchor), + thumbnailImageView.heightAnchor.constraint(equalToConstant: 95) + ]) + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: thumbnailImageView.bottomAnchor, constant: make.space24), + titleLabel.centerXAnchor.constraint(equalTo: noticeView.centerXAnchor), + titleLabel.widthAnchor.constraint(equalTo: noticeView.widthAnchor), + titleLabel.heightAnchor.constraint(equalToConstant: 24) + ]) + + NSLayoutConstraint.activate([ + subTitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: make.space2), + subTitleLabel.centerXAnchor.constraint(equalTo: titleLabel.centerXAnchor), + subTitleLabel.widthAnchor.constraint(equalToConstant: 264), + subTitleLabel.heightAnchor.constraint(equalToConstant: 18) + ]) + + NSLayoutConstraint.activate([ + confirmButton.bottomAnchor.constraint(equalTo: noticeView.bottomAnchor, constant: -26), + confirmButton.centerXAnchor.constraint(equalTo: self.centerXAnchor), + confirmButton.widthAnchor.constraint(equalToConstant: 206), + confirmButton.heightAnchor.constraint(equalToConstant: 44) + ]) + + } + + private func setUpStyle() { + self.alpha = 0.0 + self.backgroundColor = UIColor(rgb: 0x7B7B7B).withAlphaComponent(0.45) + } + + private func setUpComponent() { + confirmButton.addTarget(self, action: #selector(didConfirmButtonTapped), for: .touchUpInside) + } + + func setCertificateNotice() { + setTitle(title: "Congratulation!") + guard let image = UIImage(named: "certificate_big.png") else { return } + setImage(uiImage: image) + setSubTitle(subTitle: "You have got CPR Angel Certificate!") + } + + func setPFResultNotice(isPass: Bool, quizResultString: String = ""){ + if isPass { + guard let image = UIImage(named: "success_heart.png") else { return } + setImage(uiImage: image) + setTitle(title: "Congratulation!") + setSubTitle(subTitle: "You are perfect!") + } else { + guard let image = UIImage(named: "fail_heart.png") else { return } + setImage(uiImage: image) + setTitle(title: "Failed \(quizResultString)") + setSubTitle(subTitle: "Try Again") + } + + } + private func setImage(uiImage: UIImage) { + thumbnailImageView.image = uiImage + } + + private func setTitle(title: String) { + titleLabel.text = title + } + + private func setSubTitle(subTitle: String) { + subTitleLabel.text = subTitle + } + + @objc func didConfirmButtonTapped() { + if noticeType == .pf { + delegate?.dismissQuizViewController() + } else { + noticeDisappear() + } + } + + func noticeAppear() { + self.superview?.isUserInteractionEnabled = false + UIView.animate(withDuration: appearAnimDuration, animations: { + self.alpha = 1.0 + }, completion: { _ in + self.superview?.isUserInteractionEnabled = true + }) + } + + func noticeDisappear() { + UIView.animate(withDuration: appearAnimDuration/2, delay: 0, animations: { + self.alpha = 0.0 + }, completion: { [weak self] _ in + self?.superview?.isUserInteractionEnabled = true + self?.removeFromSuperview() + }) + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Quiz/EducationQuizViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Quiz/EducationQuizViewController.swift new file mode 100644 index 0000000..c75621e --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Quiz/EducationQuizViewController.swift @@ -0,0 +1,279 @@ +// +// EducationQuizViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/10. +// + +import UIKit +import Combine + +final class EducationQuizViewController: UIViewController { + + private lazy var questionView = QuizQuestionView(questionNumber: 1, question: "When you find someone who has fallen, you have to compress his chest instantly.") + + private lazy var oxChoiceView: OXQuizChoiceView = { + let view = OXQuizChoiceView(viewModel: viewModel) + view.alpha = 0 + return view + }() + + private lazy var multiChoiceView: MultiQuizChoiceView = { + let view = MultiQuizChoiceView(viewModel: viewModel) + view.alpha = 0 + return view + }() + + private lazy var noticeView = CustomNoticeView(noticeAs: .pf) + + private lazy var answerLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 18) + label.textColor = .mainBlack + label.textAlignment = .center + label.isUserInteractionEnabled = false + return label + }() + + private lazy var answerDescriptionLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 18) + label.textColor = .mainBlack + label.textAlignment = .center + label.numberOfLines = 3 + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.5 + label.isUserInteractionEnabled = false + return label + }() + + private let submitButton: UIButton = { + let button = UIButton() + button.backgroundColor = .mainLightRed + button.setTitleColor(.mainBlack, for: .normal) + button.titleLabel?.font = UIFont(weight: .bold, size: 20) + button.setTitle("Confirm", for: .normal) + return button + }() + + private let viewModel = QuizViewModel() + private var cancellables = Set() + + weak var delegate: EducationMainViewControllerDelegate? + + override func viewDidLoad() { + super.viewDidLoad() + + setUpConstraints() + setUpStyle() + setUpDelegate() + Task { + try await viewModel.receiveQuizList() + updateQuiz(quiz: viewModel.currentQuiz()) + } + bind(to: viewModel) + } + + private func setUpConstraints() { + let safeArea = view.safeAreaLayoutGuide + let make = Constraints.shared + + [ + questionView, + oxChoiceView, + multiChoiceView, + submitButton, + answerLabel, + answerDescriptionLabel, + noticeView + ].forEach({ + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + questionView.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: make.space24), + questionView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: make.space16), + questionView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -make.space16), + questionView.heightAnchor.constraint(equalToConstant: 148) + + ]) + + [oxChoiceView, multiChoiceView].forEach({ choiceView in + choiceView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: make.space16).isActive = true + choiceView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -make.space16).isActive = true + }) + + NSLayoutConstraint.activate([ + oxChoiceView.topAnchor.constraint(equalTo: questionView.bottomAnchor, constant: 78), + oxChoiceView.heightAnchor.constraint(equalToConstant: 80), + multiChoiceView.topAnchor.constraint(equalTo: questionView.bottomAnchor, constant: 36), + multiChoiceView.heightAnchor.constraint(equalToConstant: 280) + ]) + + NSLayoutConstraint.activate([ + submitButton.bottomAnchor.constraint(equalTo: view.bottomAnchor), + submitButton.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor), + submitButton.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor), + submitButton.heightAnchor.constraint(equalToConstant: 80) + ]) + + NSLayoutConstraint.activate([ + answerLabel.topAnchor.constraint(equalTo: questionView.bottomAnchor, constant: 200), + answerLabel.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor), + answerLabel.widthAnchor.constraint(equalToConstant: 300), + answerLabel.heightAnchor.constraint(equalToConstant: 24) + ]) + + NSLayoutConstraint.activate([ + answerDescriptionLabel.topAnchor.constraint(equalTo: answerLabel.bottomAnchor), + answerDescriptionLabel.centerXAnchor.constraint(equalTo: answerLabel.centerXAnchor), + answerDescriptionLabel.widthAnchor.constraint(equalToConstant: 300), + answerDescriptionLabel.heightAnchor.constraint(equalToConstant: 50) + ]) + + NSLayoutConstraint.activate([ + noticeView.topAnchor.constraint(equalTo: view.topAnchor), + noticeView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + noticeView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + noticeView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + private func setUpStyle() { + view.backgroundColor = .white + + navigationController?.navigationBar.topItem?.title = "Quiz" + let closeItem = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(closeButtonTapped)) + navigationItem.leftBarButtonItem = closeItem + } + + private func setUpDelegate() { + noticeView.delegate = self + } +} + +// MARK: ViewModel Binding +extension EducationQuizViewController { + private func bind(to viewModel: QuizViewModel) { + viewModel.selectedAnswerIndex.sink { index in + if (index != -1) { + viewModel.isSelected() + } + } + .store(in: &cancellables) + + submitButton.tapPublisher.sink { [weak self] _ in + self?.nextQuiz() + }.store(in: &cancellables) + } + + private func nextQuiz() { + let output = viewModel.transform() + + output.isCorrect?.sink { [weak self] isCorrect in + + guard let currentQuiz = self?.viewModel.currentQuiz() else { return } + self?.answerLabel.isHidden = false + self?.answerDescriptionLabel.isHidden = false + self?.answerLabel.text = isCorrect ? "Correct!" : "Wrong!" + self?.answerDescriptionLabel.text = currentQuiz.answerDescription + self?.submitButton.setTitle("Next", for: .normal) + + guard let answerIndex = self?.viewModel.currentQuiz().answerIndex, let quizType = self?.viewModel.currentQuiz().questionType else { + return } + + switch quizType { + case .ox: + self?.oxChoiceView.animateChoiceButton(answerIndex: answerIndex) + self?.oxChoiceView.interactionEnabled(to: false) + case .multi: + self?.multiChoiceView.animateChoiceButton(answerIndex: answerIndex) + self?.multiChoiceView.interactionEnabled(to: false) + } + + }.store(in: &cancellables) + + output.quiz?.sink { quiz in + self.updateQuiz(quiz: quiz) + + switch quiz.questionType { + case .ox: + self.oxChoiceView.interactionEnabled(to: true) + case .multi: + self.multiChoiceView.interactionEnabled(to: true) + } + }.store(in: &cancellables) + + output.isQuizEnd.sink { [weak self] isQuizEnd in + guard let isQuizAllCorrect = self?.viewModel.isQuizAllCorrect() else { return } + guard let quizResultString = self?.viewModel.quizResultString() else { return } + + if isQuizEnd { + if isQuizAllCorrect { + Task { + try await self?.viewModel.saveQuizResult() + self?.noticeView.setPFResultNotice(isPass: true) + self?.noticeView.noticeAppear() + } + } else { + self?.noticeView.setPFResultNotice(isPass: false, quizResultString: quizResultString) + self?.noticeView.noticeAppear() + } + } + }.store(in: &cancellables) + } + + private func updateQuiz(quiz: Quiz) { + viewModel.updateSelectedAnswerIndex(index: -1) + questionView.setUpText(questionNumber: quiz.questionNumber, question: quiz.question) + + switch quiz.questionType { + case .ox: + updateChoiceView(current: multiChoiceView, as: oxChoiceView) + oxChoiceView.setUpText() + case .multi: + updateChoiceView(current: oxChoiceView, as: multiChoiceView) + multiChoiceView.setUpText(quiz.answerList) + } + + oxChoiceView.resetChoiceButtonConstraint() + multiChoiceView.resetChoiceButtonConstraint() + + [answerLabel, answerDescriptionLabel].forEach{ $0.isHidden = true } + answerDescriptionLabel.text = quiz.answerDescription + submitButton.setTitle("Confirm", for: .normal) + } + + private func updateChoiceView(current: QuizChoiceView, as will: QuizChoiceView) { + current.alpha = 0.0 + current.isUserInteractionEnabled = false + will.alpha = 1.0 + will.isUserInteractionEnabled = true + } +} + +// MARK: Objc Function +extension EducationQuizViewController { + @objc private func closeButtonTapped() { + let alert = UIAlertController(title: "Quiz Exit", message: "All progress will be lost", preferredStyle: .alert) + + let confirm = UIAlertAction(title: "Confirm", style: .destructive, handler: { _ in + self.dismiss(animated: true) + }) + + let cancel = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + [confirm, cancel].forEach { + alert.addAction($0) + } + + present(alert, animated: true, completion: nil) + } +} + +// MARK: Delegate +extension EducationQuizViewController: CustomNoticeViewDelegate { + func dismissQuizViewController() { + delegate?.updateUserEducationStatus() + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Quiz/MultiQuizChoiceView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Quiz/MultiQuizChoiceView.swift new file mode 100644 index 0000000..66f21f7 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Quiz/MultiQuizChoiceView.swift @@ -0,0 +1,105 @@ +// +// MultiQuizChoiceView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/18. +// + +import UIKit + +final class MultiQuizChoiceView: QuizChoiceView { + + private var answerCenterPosition = CGPoint() + private var defaultPosition = CGPoint() + private var selectedChoice = UIButton() + + init (viewModel: QuizViewModel) { + super.init(quizType: .multi, viewModel: viewModel) + + setUpConstraints() + setUpText() + setUpStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + required init(quizType: QuizType, viewModel: QuizViewModel) { + fatalError("init(quizType:viewModel:) has not been implemented") + } + + override func setUpConstraints() { + let stackView = UIStackView() + stackView.axis = NSLayoutConstraint.Axis.vertical + stackView.distribution = UIStackView.Distribution.equalSpacing + stackView.alignment = UIStackView.Alignment.center + stackView.spacing = 26 + + self.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + + + choices.forEach({ + stackView.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + + $0.widthAnchor.constraint(equalToConstant: 334).isActive = true + $0.heightAnchor.constraint(equalToConstant: 52).isActive = true + $0.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true + }) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: self.topAnchor), + stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + stackView.centerXAnchor.constraint(equalTo: self.centerXAnchor), + stackView.widthAnchor.constraint(equalToConstant: 260) + ]) + + NSLayoutConstraint.activate([ + choices[0].topAnchor.constraint(equalTo: stackView.topAnchor), + choices[1].topAnchor.constraint(equalTo: choices[0].bottomAnchor, constant: 26), + choices[2].topAnchor.constraint(equalTo: choices[1].bottomAnchor, constant: 26), + choices[3].topAnchor.constraint(equalTo: choices[2].bottomAnchor, constant: 26) + ]) + + answerCenterPosition = choices[1].center + } + + override func setUpStyle() { + choices.forEach({ + $0.backgroundColor = UIColor.mainRed.withAlphaComponent(0.05) + $0.layer.borderWidth = 2 + $0.layer.borderColor = UIColor.mainRed.cgColor + $0.layer.cornerRadius = 20 + $0.titleLabel?.font = UIFont(weight: .regular, size: 26) + $0.titleLabel?.minimumScaleFactor = 0.15 + $0.titleLabel?.numberOfLines = 1 + $0.titleLabel?.adjustsFontSizeToFitWidth = true + $0.titleLabel?.lineBreakMode = NSLineBreakMode.byClipping + $0.setTitleColor(.mainBlack, for: .normal) + }) + } + + override func animateChoiceButton(answerIndex: Int) { + var otherButtons: [UIButton] = [] + for index in 0..() + + required init(quizType: QuizType, viewModel: QuizViewModel) { + super.init(frame: CGRect.zero) + + for _ in 0.. 10 ? "0\(questionNumber)" : String(questionNumber) + questionNumberLabel.text = "Q. \(number)" + questionLabel.text = question + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Quiz/QuizViewModel.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Quiz/QuizViewModel.swift new file mode 100644 index 0000000..ec957d7 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Education/Quiz/QuizViewModel.swift @@ -0,0 +1,113 @@ +// +// QuizViewModel.swift +// CPR2U +// +// Created by 황정현 on 2023/03/14. +// + +import Foundation +import Combine + +class QuizViewModel: OutputOnlyViewModelType { + private var eduManager: EducationManager + private var quizList: [Quiz] = [] + private var currentQuizIndex: Int = 0 + private var didSelectAnswer: Bool = false + private var correctQuizNum: Int = 0 + var selectedAnswerIndex = CurrentValueSubject(-1) + + init() { + eduManager = EducationManager(service: APIManager()) + } + + struct Output { + let quiz: CurrentValueSubject? + let isCorrect: CurrentValueSubject? + let isQuizEnd: CurrentValueSubject + } + + func isSelected() { + didSelectAnswer = true + } + + func isConfirmed() { + didSelectAnswer = false + } + + func quizInit() -> Quiz { + return quizList[0] + } + + func currentQuiz() -> Quiz { + return quizList[currentQuizIndex] + } + func currentQuizType() -> QuizType { + return quizList[currentQuizIndex].questionType + } + func updateSelectedAnswerIndex(index: Int) { + selectedAnswerIndex.send(index) + } + + func quizResultString() -> String { + return "\(correctQuizNum)/\(quizList.count)" + } + + func isQuizAllCorrect() -> Bool { + return correctQuizNum == quizList.count + } + + func transform() -> Output { + + if selectedAnswerIndex.value == -1 { + return Output(quiz: nil, isCorrect: nil, isQuizEnd: CurrentValueSubject(false)) + } + + var output: Output + + if didSelectAnswer { + let index = selectedAnswerIndex.value + let isCorrect = currentQuiz().answerIndex == index + + if isCorrect { + correctQuizNum += 1 + } + output = Output(quiz: nil, isCorrect: CurrentValueSubject(isCorrect), isQuizEnd: CurrentValueSubject(false)) + didSelectAnswer.toggle() + } else { + currentQuizIndex += 1 + if quizList.count == currentQuizIndex { + output = Output(quiz: nil, isCorrect: nil, isQuizEnd: CurrentValueSubject(true)) + } else { + output = Output(quiz: CurrentValueSubject(quizList[currentQuizIndex]), isCorrect: nil, isQuizEnd: CurrentValueSubject(false)) + selectedAnswerIndex.send(-1) + didSelectAnswer.toggle() + } + } + return output + } + + func receiveQuizList() async throws { + let result = Task { () -> [QuizInfo]? in + let eduResult = try await eduManager.getQuizList() + return eduResult.data + } + + do { + let data = try await result.value + data?.forEach({ item in + + guard let answerIndex = item.answer_list.map({ $0.id }).firstIndex(of: item.answer) else { return } + let answerList = item.answer_list.map { $0.content } + let quiz = Quiz(questionType: item.type == 0 ? .ox : .multi, questionNumber: item.index, question: item.question, answerIndex: answerIndex, answerList: answerList, answerDescription: item.reason) + quizList.append(quiz) + }) + } catch(let error) { + print(error) + } + } + + func saveQuizResult() async throws{ + (_, _) = try await eduManager.saveQuizResult(score: 100) + } + +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Login/View/AuthViewModel.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Login/View/AuthViewModel.swift new file mode 100644 index 0000000..8296062 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Login/View/AuthViewModel.swift @@ -0,0 +1,149 @@ +// +// AuthViewModel.swift +// CPR2U +// +// Created by 황정현 on 2023/03/06. +// + +import Foundation +import Combine + +enum LoginPhase { + case PhoneNumber + case SMSCode + case Nickname +} + +protocol ViewModelType { + associatedtype Input + associatedtype Output + + func transform(loginPhase: LoginPhase, input: Input) -> Output +} + +final class AuthViewModel: ViewModelType { + + private let authManager: AuthManager + private var phoneNumberString: String? + private var smsCode: String? + private var nickname: String? + + init() { + authManager = AuthManager(service: APIManager()) + } + + func getPhoneNumber() -> String { + guard let str = phoneNumberString else { return "" } + return "+82 \(str)" + } + + func getSMSCode() -> String { + guard let str = smsCode else { return "" } + return str + } + + func getNickname() -> String { + guard let str = nickname else { return "" } + return str + } + + func setPhoneNumber(number: String) { + phoneNumberString = number + } + + func setSMSCode(number: String) { + smsCode = number + } + + func setNickname(name: String) { + nickname = name + } + + func autoLogin() async throws -> Bool { + let refreshToken = UserDefaultsManager.refreshToken + let result = Task { + if refreshToken == "" { + return false + } else { + let authResult = try await authManager.autoLogin(refreshToken: refreshToken) + if authResult.success == true { + guard let data = authResult.data else { return false } + UserDefaultsManager.refreshToken = data.refresh_token + UserDefaultsManager.accessToken = data.access_token + } + return authResult.success + } + } + return try await result.value + } + + func phoneNumberVerify(phoneNumber: String) async throws -> String? { + let taskResult = Task { () -> String? in + var result: SMSCodeResult? + do { + (_, result) = try await authManager.phoneNumberVerify(phoneNumber: phoneNumber) + } catch (let error) { + print(error) + } + + guard let validationCode = result?.validation_code else { return nil} + print("인증번호 \(validationCode)") + return validationCode + } + + return await taskResult.value + } + + func userVerify() async throws -> Bool { + let result = Task { () -> Bool in + guard let phoneNumber = phoneNumberString else { return false } + let authResult = try await authManager.signIn(phoneNumber: phoneNumber, deviceToken: DeviceTokenManager.deviceToken) + + return authResult.success + } + return try await result.value + } + + func nicknameVerify(userInput: String) async throws -> NicknameStatus { + let taskResult = Task { () -> Bool in + let authResult = try await authManager.nicknameVerify(nickname: userInput) + return authResult.success + } + + if try await taskResult.value == true { + return .available + } else { + return .unavailable + } + } + + func signUp() async throws -> Bool { + let taskResult = Task { () -> Bool in + guard let phoneNumber = phoneNumberString, let nickname = nickname else { return false } + let authResult = try await authManager.signUp(nickname: nickname, phoneNumber: phoneNumber, deviceToken: DeviceTokenManager.deviceToken) + if authResult.success == true { + guard let data = authResult.data else { return false } + UserDefaultsManager.accessToken = data.access_token + UserDefaultsManager.refreshToken = data.refresh_token + print("USER TOKEN UPDATE") + } + return authResult.success + } + return try await taskResult.value + } + + struct Input { + let verifier: AnyPublisher + } + + struct Output { + let buttonIsValid: AnyPublisher + } + + func transform(loginPhase: LoginPhase, input: Input) -> Output { + let buttonStatePublisher = input.verifier.map { verifier in + verifier.count > 0 + }.eraseToAnyPublisher() + return Output(buttonIsValid: buttonStatePublisher) + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Login/View/NicknameVertificationViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Login/View/NicknameVertificationViewController.swift new file mode 100644 index 0000000..b255e0b --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Login/View/NicknameVertificationViewController.swift @@ -0,0 +1,275 @@ +// +// NicknameVerificationViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/04. +// + +import Combine +import CombineCocoa +import UIKit + +enum NicknameStatus { + case specialCharacters + case unavailable + case available + case none + + func changeNoticeLabel(noticeLabel: UILabel, nickname: String?) { + + let name = nickname ?? "" + var str: String + switch self { + case .specialCharacters: + str = "Nickname cannot contain special characters" + case .unavailable: + str = "\'\(name)' is Unavailable" + case .available: + str = "" + case .none: + str = "" + } + + noticeLabel.text = str + + } + + func changeNoticeViewLayerBorderColor(view: UIView) { + if self == .unavailable { + view.layer.borderColor = UIColor.mainRed.cgColor + } else { + view.layer.borderColor = UIColor(rgb:0xF2F2F2).cgColor + } + } +} + +final class NicknameVerificationViewController: UIViewController { + + private let signManager = AuthManager(service: APIManager()) + + var phoneNumberString: String? + + private let instructionLabel = UILabel() + private let descriptionLabel = UILabel() + + private let nicknameView = UIView() + private let nicknameTextField = UITextField() + + private let irregularNoticeLabel = UILabel() + + private let continueButton = UIButton() + + private var continueButtonBottomConstraints = NSLayoutConstraint() + + private var nicknameStatus: NicknameStatus = NicknameStatus.none { + willSet(newValue) { + newValue.changeNoticeLabel(noticeLabel: irregularNoticeLabel, nickname: nicknameTextField.text) + newValue.changeNoticeViewLayerBorderColor(view: nicknameView) + } + } + + private var viewModel: AuthViewModel + private var cancellables = Set() + + init(viewModel: AuthViewModel) { + self.viewModel = viewModel + self.phoneNumberString = viewModel.getPhoneNumber() + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setUpConstraints() + setUpStyle() + setUpText() + setUpAction() + setUpKeyboard() + bind(viewModel: viewModel) + } + + private func setUpConstraints() { + + let space4: CGFloat = 4 + let space8: CGFloat = 8 + let space16: CGFloat = 16 + + let safeArea = view.safeAreaLayoutGuide + + [ + instructionLabel, + descriptionLabel, + nicknameView, + irregularNoticeLabel, + continueButton + ].forEach({ + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + nicknameView.addSubview(nicknameTextField) + nicknameTextField.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + instructionLabel.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: space16), + instructionLabel.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: space16), + instructionLabel.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -space16), + instructionLabel.heightAnchor.constraint(equalToConstant: 32) + ]) + + NSLayoutConstraint.activate([ + descriptionLabel.topAnchor.constraint(equalTo: instructionLabel.bottomAnchor, constant: space4), + descriptionLabel.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: space16), + descriptionLabel.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: space16), + descriptionLabel.heightAnchor.constraint(equalToConstant: 28) + ]) + + NSLayoutConstraint.activate([ + nicknameView.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: space8), + nicknameView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: space16), + nicknameView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -space16), + nicknameView.heightAnchor.constraint(equalToConstant: 48) + ]) + + NSLayoutConstraint.activate([ + nicknameTextField.topAnchor.constraint(equalTo: nicknameView.topAnchor), + nicknameTextField.leadingAnchor.constraint(equalTo: nicknameView.leadingAnchor, constant: space16), + nicknameTextField.trailingAnchor.constraint(equalTo: nicknameView.trailingAnchor), + nicknameTextField.heightAnchor.constraint(equalTo: nicknameView.heightAnchor) + ]) + + NSLayoutConstraint.activate([ + irregularNoticeLabel.topAnchor.constraint(equalTo: nicknameTextField.bottomAnchor, constant: space8), + irregularNoticeLabel.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: space16), + irregularNoticeLabel.widthAnchor.constraint(equalToConstant: 300), + irregularNoticeLabel.heightAnchor.constraint(equalToConstant: 16), + ]) + + continueButtonBottomConstraints = continueButton.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -space16) + NSLayoutConstraint.activate([ + continueButton.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: space16), + continueButton.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -space16), + continueButtonBottomConstraints, + continueButton.heightAnchor.constraint(equalToConstant: 55) + ]) + } + + private func setUpStyle() { + + view.backgroundColor = .white + + instructionLabel.font = UIFont(weight: .bold, size: 24) + instructionLabel.textColor = .mainBlack + descriptionLabel.font = UIFont(weight: .regular, size: 14) + descriptionLabel.textColor = .mainBlack + + nicknameView.layer.borderColor = UIColor(rgb:0xF2F2F2).cgColor + nicknameView.layer.borderWidth = 1 + nicknameView.layer.cornerRadius = 6 + + nicknameTextField.backgroundColor = .clear + nicknameTextField.textColor = .mainBlack + nicknameTextField.font = UIFont(weight: .regular, size: 16) + + irregularNoticeLabel.font = UIFont(weight: .regular, size: 14) + irregularNoticeLabel.textAlignment = .left + irregularNoticeLabel.textColor = .mainRed + + continueButton.titleLabel?.font = UIFont(weight: .bold, size: 16) + continueButton.setTitleColor(.mainWhite, for: .normal) + continueButton.backgroundColor = .mainRed + continueButton.layer.cornerRadius = 27.5 + } + + private func setUpText() { + instructionLabel.text = "Enter your Nickname" + descriptionLabel.text = "People can recognize you by your nickname" + continueButton.setTitle("CONTINUE", for: .normal) + + nicknameTextField.placeholder = "Nickname*" + } + + private func setUpAction() { + nicknameTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) + } + + private func setUpKeyboard() { + nicknameTextField.becomeFirstResponder() + NotificationCenter.default.addObserver(self, selector:#selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector:#selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil) + hideKeyboardWhenTappedAround() + } + + @objc func textFieldDidChange(_ textField: UITextField) { + guard let str = textField.text else { return } + + if str.count > 20 { + textField.text?.removeLast() + } + + let strArr = Array(str) + let pattern = "^[가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z0-9]$" + + if strArr.count > 0 { + if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) { + for index in 0.. 0 else { return } + if (self?.nicknameStatus != .specialCharacters) { + let nicknameStatus = try await self?.viewModel.nicknameVerify(userInput: userInput) + if nicknameStatus == .available { + self?.viewModel.setNickname(name: userInput) + let signUpResult = try await self?.viewModel.signUp() + if signUpResult == true { + self?.dismiss(animated: true) + let vc = TabBarViewController() + guard let window = self?.view.window else { return } + await window.setRootViewController(vc, animated: true) + } else { + print("에러") + } + } else { + guard let label = self?.irregularNoticeLabel else { return } + nicknameStatus?.changeNoticeLabel(noticeLabel: label, nickname: userInput) + } + } + } + }.store(in: &cancellables) + } + + @objc private func keyboardWillShow(_ notification: Notification) { + if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { + let keyboardHeight = keyboardFrame.cgRectValue.height + + continueButtonBottomConstraints.constant = -keyboardHeight + view.layoutIfNeeded() + } + } + + @objc private func keyboardWillHide(_ notification: Notification) { + continueButtonBottomConstraints.constant = -16 + view.layoutIfNeeded() + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Login/View/PhoneNumberVertificationViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Login/View/PhoneNumberVertificationViewController.swift new file mode 100644 index 0000000..9aaf44d --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Login/View/PhoneNumberVertificationViewController.swift @@ -0,0 +1,228 @@ +// +// PhoneNumberVerificationViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/02. +// + +import Combine +import CombineCocoa +import UIKit + +final class PhoneNumberVerificationViewController: UIViewController { + + private let instructionLabel = UILabel() + private let descriptionLabel = UILabel() + + private let phoneNumberView = UIView() + private let phoneNumberNationView = UIView() + private let phoneNumberNationLabel = UILabel() + private let phoneNumberTextField = UITextField() + + private let sendButton: UIButton = { + let button = UIButton() + button.isEnabled = false + return button + }() + + private var sendButtonBottomConstraints = NSLayoutConstraint() + + private var viewModel: AuthViewModel + private var cancellables = Set() + + init(viewModel: AuthViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setUpConstraints() + setUpStyle() + setUpText() + setUpKeyboard() + bind(to: viewModel) + } + + private func setUpConstraints() { + + let space4: CGFloat = 4 + let space8: CGFloat = 8 + let space16: CGFloat = 16 + + let safeArea = view.safeAreaLayoutGuide + + [ + instructionLabel, + descriptionLabel, + phoneNumberView, + sendButton + ].forEach({ + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + [ + phoneNumberNationView, + phoneNumberTextField + ].forEach({ + phoneNumberView.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + instructionLabel.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: space16), + instructionLabel.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: space16), + instructionLabel.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -space16), + instructionLabel.heightAnchor.constraint(equalToConstant: 32) + ]) + + NSLayoutConstraint.activate([ + descriptionLabel.topAnchor.constraint(equalTo: instructionLabel.bottomAnchor, constant: space4), + descriptionLabel.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: space16), + descriptionLabel.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: space16), + descriptionLabel.heightAnchor.constraint(equalToConstant: 28) + ]) + + NSLayoutConstraint.activate([ + phoneNumberView.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: space8), + phoneNumberView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: space16), + phoneNumberView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -space16), + phoneNumberView.heightAnchor.constraint(equalToConstant: 48) + ]) + + NSLayoutConstraint.activate([ + phoneNumberNationView.topAnchor.constraint(equalTo: phoneNumberView.topAnchor), + phoneNumberNationView.leadingAnchor.constraint(equalTo: phoneNumberView.leadingAnchor), + phoneNumberNationView.widthAnchor.constraint(equalToConstant: 72), + phoneNumberNationView.heightAnchor.constraint(equalTo: phoneNumberView.heightAnchor) + ]) + + phoneNumberNationView.addSubview(phoneNumberNationLabel) + phoneNumberNationLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + phoneNumberNationLabel.topAnchor.constraint(equalTo: phoneNumberNationView.topAnchor), + phoneNumberNationLabel.bottomAnchor.constraint(equalTo: phoneNumberNationView.bottomAnchor), + phoneNumberNationLabel.leadingAnchor.constraint(equalTo: phoneNumberNationView.leadingAnchor), + phoneNumberNationLabel.trailingAnchor.constraint(equalTo: phoneNumberNationView.trailingAnchor) + ]) + + NSLayoutConstraint.activate([ + phoneNumberTextField.topAnchor.constraint(equalTo: phoneNumberView.topAnchor), + phoneNumberTextField.leadingAnchor.constraint(equalTo: phoneNumberNationView.trailingAnchor, constant: space16), + phoneNumberTextField.trailingAnchor.constraint(equalTo: phoneNumberView.trailingAnchor), + phoneNumberTextField.heightAnchor.constraint(equalTo: phoneNumberView.heightAnchor) + ]) + + sendButtonBottomConstraints = sendButton.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -space16) + NSLayoutConstraint.activate([ + sendButton.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: space16), + sendButton.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -space16), + sendButtonBottomConstraints, + sendButton.heightAnchor.constraint(equalToConstant: 55) + ]) + } + + private func setUpStyle() { + + view.backgroundColor = .white + + instructionLabel.font = UIFont(weight: .bold, size: 24) + instructionLabel.textColor = .mainBlack + descriptionLabel.font = UIFont(weight: .regular, size: 14) + descriptionLabel.textColor = .mainBlack + + phoneNumberNationView.clipsToBounds = true + phoneNumberNationView.layer.cornerRadius = 6 + phoneNumberNationView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner] + phoneNumberNationView.backgroundColor = UIColor(rgb:0xF2F2F2) + + phoneNumberNationLabel.font = UIFont(weight: .regular, size: 15) + phoneNumberNationLabel.textAlignment = .center + phoneNumberNationLabel.textColor = .mainBlack + + phoneNumberView.layer.borderColor = UIColor(rgb:0xF2F2F2).cgColor + phoneNumberView.layer.borderWidth = 1 + phoneNumberView.layer.cornerRadius = 6 + + phoneNumberTextField.backgroundColor = .clear + phoneNumberTextField.textColor = .mainBlack + phoneNumberTextField.font = UIFont(weight: .regular, size: 16) + + sendButton.titleLabel?.font = UIFont(weight: .bold, size: 16) + sendButton.setTitleColor(.mainBlack, for: .normal) + sendButton.backgroundColor = .mainLightGray + sendButton.layer.cornerRadius = 27.5 + } + + private func setUpText() { + instructionLabel.text = "Enter your number" + descriptionLabel.text = "We will send a code to verify your mobile number" + + phoneNumberNationLabel.text = "+ 82" + + phoneNumberTextField.placeholder = "PhoneNumber*" + sendButton.setTitle("SEND", for: .normal) + } + + private func setUpKeyboard() { + phoneNumberTextField.becomeFirstResponder() + phoneNumberTextField.keyboardType = .numberPad + NotificationCenter.default.addObserver(self, selector:#selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector:#selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil) + hideKeyboardWhenTappedAround() + } + + private func bind(to viewModel: AuthViewModel) { + let input = AuthViewModel.Input( + verifier: phoneNumberTextField.textPublisher.eraseToAnyPublisher() + ) + + let output = viewModel.transform(loginPhase: LoginPhase.PhoneNumber, input: input) + + output + .buttonIsValid + .sink(receiveValue: { [weak self] state in + self?.sendButton.isEnabled = state + self?.sendButton.setTitleColor(state ? .mainWhite : .mainBlack, for: .normal) + self?.sendButton.backgroundColor = state ? .mainRed : .mainLightGray + }) + .store(in: &cancellables) + + sendButton.tapPublisher.sink { + guard let phoneNumberString = self.phoneNumberTextField.text else { return } + Task { + guard let smsCode = try await self.viewModel.phoneNumberVerify(phoneNumber: phoneNumberString) else { return } + self.navigateToSMSCodeVerificationPage(phoneNumberString: phoneNumberString, smsCode: smsCode) + } + + }.store(in: &cancellables) + } + + @objc private func keyboardWillShow(_ notification: Notification) { + if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { + let keyboardHeight = keyboardFrame.cgRectValue.height + + sendButtonBottomConstraints.constant = -keyboardHeight + view.layoutIfNeeded() + } + } + + @objc private func keyboardWillHide(_ notification: Notification) { + sendButtonBottomConstraints.constant = -16 + view.layoutIfNeeded() + } +} + +extension PhoneNumberVerificationViewController { + func navigateToSMSCodeVerificationPage(phoneNumberString: String, smsCode: String) { + viewModel.setPhoneNumber(number: phoneNumberString) + viewModel.setSMSCode(number: smsCode) + self.navigationController?.pushViewController(SMSCodeVerificationViewController(viewModel: viewModel), animated: true) + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Login/View/SMSCodeInputView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Login/View/SMSCodeInputView.swift new file mode 100644 index 0000000..1abccfc --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Login/View/SMSCodeInputView.swift @@ -0,0 +1,66 @@ +// +// SMSCodeInputView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/04. +// + +import UIKit + +final class SMSCodeInputView: UIView { + + private var isHighlight = true { + didSet(oldValue) { + if oldValue == true { + self.backgroundColor = .mainLightRed + self.layer.borderWidth = 0 + } else { + self.backgroundColor = .white + self.layer.borderWidth = 2 + } + } + } + var smsCodeTextField = UITextField() + + override init(frame: CGRect) { + super.init(frame: frame) + + setUpConstraints() + setUpStyle() + setUpKeyboard() + + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setUpConstraints() { + + self.addSubview(smsCodeTextField) + smsCodeTextField.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + smsCodeTextField.centerXAnchor.constraint(equalTo: self.centerXAnchor), + smsCodeTextField.centerYAnchor.constraint(equalTo: self.centerYAnchor), + smsCodeTextField.widthAnchor.constraint(equalToConstant: 16), + smsCodeTextField.heightAnchor.constraint(equalToConstant: 40), + ]) + } + + func setUpStyle() { + self.backgroundColor = UIColor(rgb: 0xFBD6D6) + + self.layer.cornerRadius = 5 + self.layer.borderColor = UIColor.mainBlack.cgColor + self.layer.borderWidth = 0 + + smsCodeTextField.font = UIFont(weight: .regular, size: 29) + smsCodeTextField.textColor = UIColor(rgb: 0xAC6767) + smsCodeTextField.textAlignment = .center + } + + func setUpKeyboard() { + smsCodeTextField.keyboardType = .numberPad + } + +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Login/View/SMSCodeVertificationViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Login/View/SMSCodeVertificationViewController.swift new file mode 100644 index 0000000..ef5685a --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Login/View/SMSCodeVertificationViewController.swift @@ -0,0 +1,283 @@ +// +// SMSCodeVerificationViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/04. +// + +import Combine +import CombineCocoa +import UIKit + +final class SMSCodeVerificationViewController: UIViewController { + private let authManager = AuthManager(service: APIManager()) + + private let instructionLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 24) + label.textColor = .mainBlack + label.text = "Enter Code" + return label + }() + + private let descriptionLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 14) + label.textColor = .mainBlack + label.text = "An SMS code was sent to" + return label + }() + + private lazy var phoneNumberLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 16) + label.textAlignment = .left + label.textColor = .mainBlack + label.text = self.viewModel.getPhoneNumber() + return label + }() + + private let smsCodeInputView1 = SMSCodeInputView() + private let smsCodeInputView2 = SMSCodeInputView() + private let smsCodeInputView3 = SMSCodeInputView() + private let smsCodeInputView4 = SMSCodeInputView() + + private let codeResendLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 14) + label.textAlignment = .right + label.textColor = .mainRed + label.text = "Not receiveing the code?" + return label + }() + + private let confirmButton: UIButton = { + let button = UIButton() + button.titleLabel?.font = UIFont(weight: .bold, size: 16) + button.setTitleColor(.mainBlack, for: .normal) + button.backgroundColor = .mainLightGray + button.layer.cornerRadius = 27.5 + button.isUserInteractionEnabled = false + button.setTitle("CONFIRM", for: .normal) + return button + }() + + private var confirmButtonBottomConstraints = NSLayoutConstraint() + + private var smsCodeCheckArr = Array(repeating: false, count: 4) { + willSet(newValue) { + let status = newValue.allSatisfy({$0}) + confirmButton.setTitleColor(status ? .mainWhite : .mainBlack, for: .normal) + confirmButton.backgroundColor = status ? .mainRed : .mainLightGray + confirmButton.isUserInteractionEnabled = status + } + } + + private var viewModel: AuthViewModel + private var cancellables = Set() + + init(viewModel: AuthViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setUpConstraints() + setUpStyle() + setUpLayerName() + setUpDelegate() + setUpKeyboard() + bind(viewModel: viewModel) + } + + private func setUpConstraints() { + + let space4: CGFloat = 4 + let space8: CGFloat = 8 + let space16: CGFloat = 16 + + let safeArea = view.safeAreaLayoutGuide + + let smsCodeInputStackView = UIStackView() + smsCodeInputStackView.axis = NSLayoutConstraint.Axis.horizontal + smsCodeInputStackView.distribution = UIStackView.Distribution.equalSpacing + smsCodeInputStackView.alignment = UIStackView.Alignment.center + smsCodeInputStackView.spacing = 12 + + [ + instructionLabel, + descriptionLabel, + phoneNumberLabel, + smsCodeInputStackView, + codeResendLabel, + confirmButton + ].forEach({ + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + instructionLabel.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: space16), + instructionLabel.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: space16), + instructionLabel.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -space16), + instructionLabel.heightAnchor.constraint(equalToConstant: 32) + ]) + + NSLayoutConstraint.activate([ + descriptionLabel.topAnchor.constraint(equalTo: instructionLabel.bottomAnchor, constant: space4), + descriptionLabel.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: space16), + descriptionLabel.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: space16), + descriptionLabel.heightAnchor.constraint(equalToConstant: 22) + ]) + + NSLayoutConstraint.activate([ + phoneNumberLabel.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: space4), + phoneNumberLabel.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: space16), + phoneNumberLabel.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -space16), + phoneNumberLabel.heightAnchor.constraint(equalToConstant: 22) + ]) + + self.view.addSubview(smsCodeInputStackView) + smsCodeInputStackView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + smsCodeInputStackView.topAnchor.constraint(equalTo: phoneNumberLabel.bottomAnchor, constant: space16), + smsCodeInputStackView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: space16), + smsCodeInputStackView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -space16), + smsCodeInputStackView.heightAnchor.constraint(equalToConstant: 54) + ]) + + [ + smsCodeInputView1, + smsCodeInputView2, + smsCodeInputView3, + smsCodeInputView4 + ].forEach({ + $0.translatesAutoresizingMaskIntoConstraints = false + smsCodeInputStackView.addArrangedSubview($0 as UIView) + + NSLayoutConstraint.activate([ + $0.topAnchor.constraint(equalTo: smsCodeInputStackView.topAnchor), + $0.widthAnchor.constraint(equalToConstant: 76), + $0.heightAnchor.constraint(equalToConstant: 54) + ]) + }) + + NSLayoutConstraint.activate([ + codeResendLabel.topAnchor.constraint(equalTo: smsCodeInputStackView.bottomAnchor, constant: space8), + codeResendLabel.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -space16), + codeResendLabel.widthAnchor.constraint(equalToConstant: 300), + codeResendLabel.heightAnchor.constraint(equalToConstant: 24), + + ]) + + confirmButtonBottomConstraints = confirmButton.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -space16) + NSLayoutConstraint.activate([ + confirmButton.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: space16), + confirmButton.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -space16), + confirmButtonBottomConstraints, + confirmButton.heightAnchor.constraint(equalToConstant: 55) + ]) + } + + private func setUpStyle() { + view.backgroundColor = .white + } + + private func setUpLayerName() { + let views = [smsCodeInputView1, smsCodeInputView2, smsCodeInputView3, smsCodeInputView4] + for index in 0.. 1 && index == 3 { + smsCodeViews[(index)].smsCodeTextField.text?.removeFirst() + } + } + .store(in: &cancellables) + } + + confirmButton.tapPublisher.sink { [self] in + Task { + if smsCodeVerify() { + let isUser = try await viewModel.userVerify() + print("USER CHECK: ", isUser) + if isUser { + let vc = TabBarViewController() + guard let window = self.view.window else { return } + await window.setRootViewController(vc, animated: true) + dismiss(animated: true) + } else { + navigationController?.pushViewController(NicknameVerificationViewController(viewModel: viewModel), animated: true) + } + } else { + print("인증코드 오류") + } + } + }.store(in: &cancellables) + } + + @objc private func keyboardWillShow(_ notification: Notification) { + if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { + let keyboardHeight = keyboardFrame.cgRectValue.height + + confirmButtonBottomConstraints.constant = -keyboardHeight + view.layoutIfNeeded() + } + } + + @objc private func keyboardWillHide(_ notification: Notification) { + confirmButtonBottomConstraints.constant = -16 + view.layoutIfNeeded() + } +} + +extension SMSCodeVerificationViewController: UITextFieldDelegate { + func textFieldDidBeginEditing(_ textField: UITextField) { + textField.text = "" + guard let textFieldLayerName = textField.layer.name else { return } + guard let index = Int(textFieldLayerName) else { return } + self.smsCodeCheckArr[index] = false + + } +} + +extension SMSCodeVerificationViewController { + func smsCodeVerify() -> Bool { + let userInput = [smsCodeInputView1, smsCodeInputView2, smsCodeInputView3, smsCodeInputView4] + .compactMap{$0.smsCodeTextField.text} + .reduce("") { return $0 + $1 } + return userInput == viewModel.getSMSCode() + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Main/TabBarViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Main/TabBarViewController.swift new file mode 100644 index 0000000..21f5e24 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Main/TabBarViewController.swift @@ -0,0 +1,56 @@ +// +// TabBarViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/22. +// + +import UIKit + +final class TabBarViewController: UITabBarController { + + init(_ selectedIndex: Int = 1) { + super.init(nibName: nil, bundle: nil) + self.selectedIndex = selectedIndex + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setUpTabBar() + } + + private func setUpTabBar() { + self.tabBar.backgroundColor = .white + + let educationVC = EducationMainViewController(viewModel: EducationViewModel()) + let callVC = CallMainViewController(viewModel: CallViewModel()) + let mypageVC = MypageViewController(viewModel: EducationViewModel()) + + educationVC.title = "Education" + callVC.title = "Call" + mypageVC.title = "Profile" + + educationVC.tabBarItem.image = UIImage.init(systemName: "book") + callVC.tabBarItem.image = UIImage.init(systemName: "bell") + mypageVC.tabBarItem.image = UIImage.init(systemName: "person") + + educationVC.navigationItem.largeTitleDisplayMode = .always + mypageVC.navigationItem.largeTitleDisplayMode = .always + + let navigationEdu = UINavigationController(rootViewController: educationVC) + let navigationCall = UINavigationController(rootViewController: callVC) + let navigationMypage = UINavigationController(rootViewController: mypageVC) + + + navigationEdu.navigationBar.prefersLargeTitles = true + navigationCall.navigationBar.isHidden = true + navigationMypage.navigationBar.prefersLargeTitles = true + + setViewControllers([navigationEdu, navigationCall, navigationMypage], animated: false) + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Mypage/MypageStatusView.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Mypage/MypageStatusView.swift new file mode 100644 index 0000000..217c57c --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Mypage/MypageStatusView.swift @@ -0,0 +1,165 @@ +// +// MypageStatusView.swift +// CPR2U +// +// Created by 황정현 on 2023/03/31. +// + +import UIKit + +final class MypageStatusView: UIView { + + private lazy var angelStatusImageView: UIImageView = { + let view = UIImageView() + return view + }() + + private lazy var nicknameLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 18) + label.textColor = .mainBlack + label.textAlignment = .center + label.numberOfLines = 2 + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.5 + return label + }() + + private lazy var angelStatusLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 20) + label.textColor = .mainRed + label.textAlignment = .center + return label + }() + + private let periodLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 18) + label.textColor = .black + label.textAlignment = .left + label.text = "Expiration period" + return label + }() + + private lazy var progressView: UIProgressView = { + let view = UIProgressView() + view.trackTintColor = .mainLightRed.withAlphaComponent(0.05) + view.progressTintColor = .mainRed + view.layer.borderColor = UIColor.mainRed.cgColor + view.layer.borderWidth = 1 + view.layer.cornerRadius = 6 + view.layer.sublayers![1].cornerRadius = 6 + view.clipsToBounds = true + return view + }() + + private lazy var expirationLabel: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .bold, size: 16) + label.textColor = .black + label.textAlignment = .right + return label + }() + + private let viewModel: EducationViewModel + + init(viewModel: EducationViewModel) { + self.viewModel = viewModel + super.init(frame: CGRect.zero) + setUpConstraints() + setUpStyle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setUpConstraints() { + [ + angelStatusImageView, + nicknameLabel, + angelStatusLabel, + periodLabel, + progressView, + expirationLabel + ].forEach({ + self.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + angelStatusImageView.topAnchor.constraint(equalTo: self.topAnchor, constant: 28), + angelStatusImageView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 35), + angelStatusImageView.widthAnchor.constraint(equalToConstant: 60), + angelStatusImageView.heightAnchor.constraint(equalToConstant: 72) + ]) + + NSLayoutConstraint.activate([ + nicknameLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: 22), + nicknameLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -25), + nicknameLabel.widthAnchor.constraint(equalToConstant: 230), + nicknameLabel.heightAnchor.constraint(equalToConstant: 45) + ]) + + NSLayoutConstraint.activate([ + angelStatusLabel.topAnchor.constraint(equalTo: nicknameLabel.bottomAnchor, constant: 8), + angelStatusLabel.centerXAnchor.constraint(equalTo: nicknameLabel.centerXAnchor), + angelStatusLabel.widthAnchor.constraint(equalToConstant: 230), + angelStatusLabel.heightAnchor.constraint(equalToConstant: 25) + ]) + + NSLayoutConstraint.activate([ + periodLabel.topAnchor.constraint(equalTo: angelStatusImageView.bottomAnchor, constant: 24), + periodLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 14), + periodLabel.widthAnchor.constraint(equalToConstant: 175), + periodLabel.heightAnchor.constraint(equalToConstant: 25) + ]) + + NSLayoutConstraint.activate([ + progressView.topAnchor.constraint(equalTo: periodLabel.bottomAnchor, constant: 8), + progressView.centerXAnchor.constraint(equalTo: self.centerXAnchor), + progressView.widthAnchor.constraint(equalToConstant: 330), + progressView.heightAnchor.constraint(equalToConstant: 12) + ]) + + NSLayoutConstraint.activate([ + expirationLabel.topAnchor.constraint(equalTo: progressView.bottomAnchor, constant: 8), + expirationLabel.trailingAnchor.constraint(equalTo: progressView.trailingAnchor), + expirationLabel.widthAnchor.constraint(equalToConstant: 200), + expirationLabel.heightAnchor.constraint(equalToConstant: 20) + ]) + } + + private func setUpStyle() { + self.layer.cornerRadius = 16 + self.layer.borderColor = UIColor.mainRed.cgColor + self.layer.borderWidth = 1 + self.backgroundColor = .mainWhite + } + + func setUpStatusComponent(certificate: CertificateStatus) { + let imgName = certificate.status.certificationImageName(true) + + var statusText: String = "" + var leftDay: Int = 0 + if let day = certificate.leftDay { + statusText = "\(certificate.status.certificationStatus()) (D-\(day))" + leftDay = day + } else { + statusText = "\(certificate.status.certificationStatus())" + } + + angelStatusImageView.image = UIImage(named: imgName) + angelStatusLabel.text = statusText + + [periodLabel, progressView, expirationLabel].forEach({$0.isHidden = (certificate.status != .acquired) }) + + progressView.progress = Float(leftDay)/90 + expirationLabel.text = leftDay.numberAsExpirationDate() + } + + func setUpGreetingLabel(nickname: String) { + nicknameLabel.text = "Hi \(nickname)\nYour Certification Status is" + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Mypage/MypageTableViewCell.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Mypage/MypageTableViewCell.swift new file mode 100644 index 0000000..de1ee39 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Mypage/MypageTableViewCell.swift @@ -0,0 +1,75 @@ +// +// MypageTableViewCell.swift +// CPR2U +// +// Created by 황정현 on 2023/03/31. +// + +import UIKit + +final class MypageTableViewCell: UITableViewCell { + static let identifier = "MypageTableViewCell" + + var icon: UIImageView = { + let view = UIImageView() + return view + }() + + var label: UILabel = { + let label = UILabel() + label.font = UIFont(weight: .regular, size: 16) + label.textAlignment = .left + return label + }() + + var chevron: UIImageView = { + let view = UIImageView() + return view + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .default, reuseIdentifier: MypageTableViewCell.identifier) + setUpConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + // Configure the view for the selected state + } + + private func setUpConstraints() { + [ + icon, + label, + chevron, + ].forEach({ + self.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + $0.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true + }) + + NSLayoutConstraint.activate([ + icon.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12), + icon.widthAnchor.constraint(equalToConstant: 17), + icon.heightAnchor.constraint(equalToConstant: 21), + ]) + + NSLayoutConstraint.activate([ + chevron.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -10), + chevron.widthAnchor.constraint(equalToConstant: 10), + chevron.heightAnchor.constraint(equalToConstant: 17), + ]) + + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10), + label.trailingAnchor.constraint(equalTo: chevron.leadingAnchor, constant: -10), + label.widthAnchor.constraint(equalToConstant: 180), + label.heightAnchor.constraint(equalToConstant: 30), + ]) + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Mypage/MypageViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Mypage/MypageViewController.swift new file mode 100644 index 0000000..092421e --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Mypage/MypageViewController.swift @@ -0,0 +1,147 @@ +// +// MypageViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/31. +// + +import Combine +import UIKit + +final class MypageViewController: UIViewController { + + private lazy var mypageStatusView: MypageStatusView = { + let view = MypageStatusView(viewModel: viewModel) + return view + }() + + private lazy var tableView: UITableView = { + let view = UITableView(frame: CGRect.zero, style: .insetGrouped) + view.backgroundColor = .white + view.sectionHeaderTopPadding = 0 + view.isScrollEnabled = false + view.showsVerticalScrollIndicator = false + return view + }() + + private var statusViewBottomAnchor: NSLayoutConstraint? + + private var viewModel: EducationViewModel + private var cancellables = Set() + + let sectionHeader = ["History", "etc", ""] + var cellDataSource = [["Dispatch History", "Call History"], ["Developer Information", "Liscence"], ["Logout"]] + init(viewModel: EducationViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setUpConstraints() + setUpStyle() + setUpTableView() + bind(viewModel: viewModel) + } + + private func setUpConstraints() { + let make = Constraints.shared + let safeArea = view.safeAreaLayoutGuide + [ + mypageStatusView, + tableView + ].forEach({ + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + }) + + NSLayoutConstraint.activate([ + mypageStatusView.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: make.space18), + mypageStatusView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + mypageStatusView.widthAnchor.constraint(equalToConstant: 358), + ]) + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: mypageStatusView.bottomAnchor), + tableView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + tableView.widthAnchor.constraint(equalToConstant: 358), + tableView.heightAnchor.constraint(equalToConstant: 390) + ]) + } + + private func setUpStyle() { + guard let navBar = self.navigationController?.navigationBar else { return } + navBar.prefersLargeTitles = true + navBar.topItem?.title = "Profile" + navBar.largeTitleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.mainRed] + self.navigationController?.navigationBar.prefersLargeTitles = true + } + + private func setUpTableView() { + tableView.register(MypageTableViewCell.self, forCellReuseIdentifier: MypageTableViewCell.identifier) + tableView.delegate = self + tableView.dataSource = self + } + + private func bind(viewModel: EducationViewModel) { + Task { + let output = try await viewModel.transform() + output.certificateStatus?.sink { [self] certificate in + statusViewBottomAnchor = mypageStatusView.heightAnchor.constraint(equalToConstant: certificate + .status == .acquired ? 222 : 124) + statusViewBottomAnchor?.isActive = true + mypageStatusView.setUpStatusComponent(certificate: certificate) + + }.store(in: &cancellables) + + output.nickname?.sink { nickname in + self.mypageStatusView.setUpGreetingLabel(nickname: nickname) + }.store(in: &cancellables) + } + } +} + +extension MypageViewController: UITableViewDelegate, UITableViewDataSource { + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return 0 + } + + func numberOfSections(in tableView: UITableView) -> Int { + return sectionHeader.count + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return sectionHeader[section] + } + + // MARK: - Row Cell + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return cellDataSource[section].count + } + + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: MypageTableViewCell.identifier, for: indexPath) as! MypageTableViewCell + + cell.backgroundColor = UIColor(rgb: 0xF2F3F6) + cell.label.text = cellDataSource[indexPath.section][indexPath.row] + print(cellDataSource[indexPath.section][indexPath.row]) + + if indexPath.section == 2 && indexPath.row == 0 { + cell.label.textAlignment = .center + cell.label.textColor = .mainRed + } else { + cell.icon.image = UIImage(named:"book.png") + cell.label.textColor = .black + cell.chevron.image = UIImage(systemName: "chevron.right")?.withTintColor(.black).withRenderingMode(.alwaysOriginal) + } + + return cell + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Scene/Splash/AutoLoginViewController.swift b/CPR2U-iOS/CPR2U/CPR2U/Scene/Splash/AutoLoginViewController.swift new file mode 100644 index 0000000..21ffe38 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Scene/Splash/AutoLoginViewController.swift @@ -0,0 +1,63 @@ +// +// AutoLoginViewController.swift +// CPR2U +// +// Created by 황정현 on 2023/03/21. +// + +import UIKit + +final class AutoLoginViewController: UIViewController { + + private let logoImageView: UIImageView = { + let view = UIImageView() + view.image = UIImage(named: "logo.png") + return view + }() + + private let viewModel = AuthViewModel() + + override func viewDidLoad() { + super.viewDidLoad() + + setUpConstraints() + setUpStyle() + checkAutoLogin() + } + + private func setUpConstraints() { + view.addSubview(logoImageView) + logoImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + logoImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + logoImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + logoImageView.widthAnchor.constraint(equalToConstant: 216), + logoImageView.heightAnchor.constraint(equalToConstant: 47) + ]) + } + + private func setUpStyle() { + view.backgroundColor = .mainRed + } + + private func checkAutoLogin() { + Task { + do { + let result = try await viewModel.autoLogin() + usleep(1200000) + if result == true { + let vc = TabBarViewController() + guard let window = self.view.window else { return } + await window.setRootViewController(vc, animated: true) + } else { + let vc = PhoneNumberVerificationViewController(viewModel: viewModel) + let navVC = UINavigationController(rootViewController: vc) + navVC.modalPresentationStyle = .overFullScreen + self.present(navVC, animated: true) + } + } catch (let error) { + print(error) + } + } + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/APIError.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/APIError.swift new file mode 100644 index 0000000..82dcccb --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/APIError.swift @@ -0,0 +1,14 @@ +// +// APIError.swift +// CPR2U +// +// Created by 황정현 on 2023/03/12. +// + +import Foundation + +enum APIError: Error { + case encodingError + case serverError + case clientError +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/EndPoint.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/EndPoint.swift new file mode 100644 index 0000000..4121604 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/EndPoint.swift @@ -0,0 +1,27 @@ +// +// EndPoint.swift +// CPR2U +// +// Created by 황정현 on 2023/03/12. +// + +import Foundation + +protocol EndPoint { + var method: HttpMethod { get } + var body: Data? { get } + + func getURL(path: String) -> String + func createRequest() -> NetworkRequest +} + +extension EndPoint { + func createRequest() -> NetworkRequest { + var headers: [String: String] = [:] + headers["Content-Type"] = "application/json" + return NetworkRequest(url: getURL(path: URLs.baseURL), + httpMethod: method, + headers: headers, + requestBody: body) + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/HttpMethod.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/HttpMethod.swift new file mode 100644 index 0000000..67cc88c --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/HttpMethod.swift @@ -0,0 +1,13 @@ +// +// HttpMethod.swift +// CPR2U +// +// Created by 황정현 on 2023/03/12. +// + +import Foundation + +enum HttpMethod: String { + case POST + case GET +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/NetworkRequest.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/NetworkRequest.swift new file mode 100644 index 0000000..cbc87bf --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/NetworkRequest.swift @@ -0,0 +1,34 @@ +// +// NetworkRequest.swift +// CPR2U +// +// Created by 황정현 on 2023/03/12. +// + +import Foundation + +struct NetworkRequest { + let url: String + let httpMethod: HttpMethod + let headers: [String: String]? + let body: Data? + + init(url: String, + httpMethod: HttpMethod, + headers: [String: String]? = nil, + requestBody: Data? = nil + ) { + self.url = url + self.httpMethod = httpMethod + self.body = requestBody + self.headers = headers + } + + func createURLRequest(with url: URL) -> URLRequest { + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = httpMethod.rawValue + urlRequest.allHTTPHeaderFields = headers ?? [:] + urlRequest.httpBody = body + return urlRequest + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/NetworkResponse.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/NetworkResponse.swift new file mode 100644 index 0000000..ada301b --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/NetworkResponse.swift @@ -0,0 +1,14 @@ +// +// NetworkResponse.swift +// CPR2U +// +// Created by 황정현 on 2023/03/12. +// + +import Foundation + +struct NetworkResponse: Decodable { + var status: Int + var message: String? + var data: T? +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/Requestable.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/Requestable.swift new file mode 100644 index 0000000..c299d62 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/APIServices/Requestable.swift @@ -0,0 +1,40 @@ +// +// Requestable.swift +// CPR2U +// +// Created by 황정현 on 2023/03/12. +// + +import Foundation + + +protocol Requestable: AnyObject { + func request(_ request: NetworkRequest) async throws -> (success: Bool, data: T?) +} + +final class APIManager: Requestable { + func request(_ request: NetworkRequest) async throws -> (success: Bool, data: T?) { + guard let encodedURL = request.url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: encodedURL) else { + throw APIError.encodingError + } + + let (data, response) = try await URLSession.shared.data(for: request.createURLRequest(with: url)) + + guard let httpResponse = response as? HTTPURLResponse, + (200..<500) ~= httpResponse.statusCode else { + throw APIError.serverError + } + + let decodedData = try JSONDecoder().decode(NetworkResponse.self, from: data) + + // MARK: TEST CODE + print("MSG: ", decodedData.message) + if decodedData.status == 200 { + return (true, decodedData.data) + } else { + return (false, decodedData.data) + } + + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Address/AddressEndPoint.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Address/AddressEndPoint.swift new file mode 100644 index 0000000..ab0d84c --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Address/AddressEndPoint.swift @@ -0,0 +1,67 @@ +// +// AddressEndPoint.swift +// CPR2U +// +// Created by 황정현 on 2023/03/29. +// + +import Foundation + +enum AddressEndPoint { + case getAddressList + case setUserAddress(id: Int) +} + +extension AddressEndPoint: EndPoint { + + var method: HttpMethod { + switch self { + case .setUserAddress: + return .POST + case .getAddressList: + return .GET + } + } + + var body: Data? { + var params: [String : Int] + switch self { + case .getAddressList: + return nil + case .setUserAddress(let id): + params = ["address_id": id] + } + + return params.encode() + } + + func getURL(path: String) -> String { + let baseURL = URLs.baseURL + switch self { + case .getAddressList: + return "\(baseURL)/users/address" + case .setUserAddress: + return "\(baseURL)/users/address" + } + } + + func createRequest() -> NetworkRequest { + let baseURL = URLs.baseURL + var headers: [String: String] = [:] + headers["Authorization"] = UserDefaultsManager.accessToken + + switch self { + case .getAddressList: + return NetworkRequest(url: getURL(path: baseURL), + httpMethod: method, + headers: headers) + case .setUserAddress: + headers["Content-Type"] = "application/json" + return NetworkRequest(url: getURL(path: baseURL), + httpMethod: method, + headers: headers, + requestBody: body) + } + } +} + diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Address/AddressManager.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Address/AddressManager.swift new file mode 100644 index 0000000..a7d84dc --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Address/AddressManager.swift @@ -0,0 +1,37 @@ +// +// AddressManager.swift +// CPR2U +// +// Created by 황정현 on 2023/03/29. +// + +import Foundation + +protocol AddressService { + func getAddressList() async throws -> (success: Bool, data: [AddressListResult]?) + func setUserAddress(id: Int) async throws -> (success: Bool, data: SetUserAddressResult?) +} + +struct AddressManager: AddressService { + + private let service: Requestable + + init(service: Requestable) { + self.service = service + } + + func getAddressList() async throws -> (success: Bool, data: [AddressListResult]?) { + let request = AddressEndPoint + .getAddressList + .createRequest() + return try await self.service.request(request) + } + + func setUserAddress(id: Int) async throws -> (success: Bool, data: SetUserAddressResult?) { + let request = AddressEndPoint + .setUserAddress(id: id) + .createRequest() + return try await self.service.request(request) + } +} + diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Auth/AuthEndPoint.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Auth/AuthEndPoint.swift new file mode 100644 index 0000000..d51f103 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Auth/AuthEndPoint.swift @@ -0,0 +1,102 @@ +// +// AuthEndPoint.swift +// CPR2U +// +// Created by 황정현 on 2023/03/11. +// + +import Foundation + +enum AuthEndPoint { + case phoneNumberVerify (phoneNumber: String) + case nicknameVerify (nickname: String) + case signIn (phoneNumber: String, deviceToken: String) + case signUp (nickname: String, phoneNumber: String, deviceToken: String) + case autoLogin (refreshToken: String) +} + +extension AuthEndPoint: EndPoint { + + var method: HttpMethod { + switch self { + case .phoneNumberVerify, .signIn, .signUp, .autoLogin: + return .POST + case .nicknameVerify: + return .GET + } + } + + var body: Data? { + var params: [String : String] + switch self { + case .phoneNumberVerify(let phoneNumber): + params = ["phone_number" : phoneNumber] + case .nicknameVerify(let nickname): + params = ["nickname" : nickname ] + case .signIn(let phoneNumber, let deviceToken): + params = [ "phone_number" : phoneNumber, "device_token" : deviceToken ] + case .signUp(let nickname, let phoneNumber, let deviceToken): + params = ["nickname" : nickname, "phone_number" : phoneNumber, "device_token" : deviceToken ] + case .autoLogin(let refreshToken) : + params = ["refresh_token" : refreshToken] + } + + return params.encode() + } + + func getURL(path: String) -> String { + let baseURL = URLs.baseURL + switch self { + case .phoneNumberVerify: + return "\(baseURL)/auth/verification" + case .nicknameVerify(let nickname): + return "\(baseURL)/auth/nickname?nickname=\(nickname)" + case .signIn: + return "\(baseURL)/auth/login" + case .signUp: + return "\(baseURL)/auth/signup" + case .autoLogin: + return "\(baseURL)/auth/auto-login" + } + } + + func createRequest() -> NetworkRequest { + let baseURL = URLs.baseURL + switch self { + case .phoneNumberVerify: + var headers: [String: String] = [:] + headers["Content-Type"] = "application/json" + return NetworkRequest(url: getURL(path: baseURL), + httpMethod: method, + headers: headers, + requestBody: body) + case .nicknameVerify: + var headers: [String: String] = [:] + headers["Content-Type"] = "application/json" + return NetworkRequest(url: getURL(path: baseURL), + httpMethod: method, + headers: headers) + case .signIn: + var headers: [String: String] = [:] + headers["Content-Type"] = "application/json" + return NetworkRequest(url: getURL(path: baseURL), + httpMethod: method, + headers: headers, + requestBody: body) + case .signUp: + var headers: [String: String] = [:] + headers["Content-Type"] = "application/json" + return NetworkRequest(url: getURL(path: baseURL), + httpMethod: method, + headers: headers, + requestBody: body) + case .autoLogin: + var headers: [String: String] = [:] + headers["Content-Type"] = "application/json" + return NetworkRequest(url: getURL(path: baseURL), + httpMethod: method, + headers: headers, + requestBody: body) + } + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Auth/AuthManager.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Auth/AuthManager.swift new file mode 100644 index 0000000..0789798 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Auth/AuthManager.swift @@ -0,0 +1,60 @@ +// +// AuthManager.swift +// CPR2U +// +// Created by 황정현 on 2023/03/12. +// + +import Foundation + +protocol AuthService { + func phoneNumberVerify(phoneNumber: String) async throws -> (success: Bool, data: SMSCodeResult?) + func nicknameVerify(nickname: String) async throws -> (success: Bool, data: NicknameVerifyResult?) + func signIn(phoneNumber: String, deviceToken: String) async throws -> (success: Bool, data: SignInResult?) + func signUp(nickname: String, phoneNumber: String, deviceToken: String) async throws -> (success: Bool, data: SignUpResult?) + func autoLogin(refreshToken: String) async throws -> (success: Bool, data: AutoLoginResult?) +} + +struct AuthManager: AuthService { + + private let service: Requestable + + init(service: Requestable) { + self.service = service + } + + func phoneNumberVerify(phoneNumber: String) async throws -> (success: Bool, data: SMSCodeResult?) { + let request = AuthEndPoint + .phoneNumberVerify(phoneNumber: phoneNumber) + .createRequest() + return try await self.service.request(request) + } + + func nicknameVerify(nickname: String) async throws -> (success: Bool, data: NicknameVerifyResult?) { + let request = AuthEndPoint + .nicknameVerify(nickname: nickname) + .createRequest() + return try await self.service.request(request) + } + + func signIn(phoneNumber: String, deviceToken: String) async throws -> (success: Bool, data: SignInResult?) { + let request = AuthEndPoint + .signIn(phoneNumber: phoneNumber, deviceToken: deviceToken) + .createRequest() + return try await self.service.request(request) + } + + func signUp(nickname: String, phoneNumber: String, deviceToken: String) async throws -> (success: Bool, data: SignUpResult?) { + let request = AuthEndPoint + .signUp(nickname: nickname, phoneNumber: phoneNumber, deviceToken: deviceToken) + .createRequest() + return try await self.service.request(request) + } + + func autoLogin(refreshToken: String) async throws -> (success: Bool, data: AutoLoginResult?) { + let request = AuthEndPoint + .autoLogin(refreshToken: refreshToken) + .createRequest() + return try await self.service.request(request) + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Call/CallEndPoint.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Call/CallEndPoint.swift new file mode 100644 index 0000000..a31b83b --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Call/CallEndPoint.swift @@ -0,0 +1,69 @@ +// +// CallEndPoint.swift +// CPR2U +// +// Created by 황정현 on 2023/03/26. +// + +import Foundation + +enum CallEndPoint { + case getCallerList + case callDispatcher(callerLocationInfo: CallerLocationInfo) + case situationEnd(callId: Int) + case countDispatcher(callId: Int) +} + +extension CallEndPoint: EndPoint { + + var method: HttpMethod { + switch self { + case .callDispatcher, .situationEnd: + return .POST + case .getCallerList, .countDispatcher: + return .GET + } + } + + var body: Data? { + switch self { + case .getCallerList: + return nil + case .callDispatcher(let callerLocationInfo): + return callerLocationInfo.encode() + case .situationEnd, .countDispatcher: + return nil + } + } + + func getURL(path: String) -> String { + let baseURL = URLs.baseURL + switch self { + case .getCallerList: + return "\(baseURL)/call" + case .callDispatcher: + return "\(baseURL)/call" + case .situationEnd(let callId): + return "\(baseURL)/call/end/\(callId)" + case .countDispatcher(let callId): + return "\(baseURL)/call/\(callId)" + } + } + + func createRequest() -> NetworkRequest { + let baseURL = URLs.baseURL + var headers: [String: String] = [:] + headers["Authorization"] = UserDefaultsManager.accessToken + switch self { + case .getCallerList: + return NetworkRequest(url: getURL(path: baseURL), httpMethod: method, headers: headers) + case .callDispatcher: + headers["Content-Type"] = "application/json" + return NetworkRequest(url: getURL(path: baseURL), httpMethod: method, headers: headers, requestBody: body) + case .situationEnd: + return NetworkRequest(url: getURL(path: baseURL), httpMethod: method, headers: headers) + case .countDispatcher: + return NetworkRequest(url: getURL(path: baseURL), httpMethod: method, headers: headers) + } + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Call/CallManager.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Call/CallManager.swift new file mode 100644 index 0000000..40db114 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Call/CallManager.swift @@ -0,0 +1,52 @@ +// +// CallManager.swift +// CPR2U +// +// Created by 황정현 on 2023/03/26. +// + +import Foundation + +protocol CallService { + func getCallerList() async throws -> (success: Bool, data: CallerListInfo?) + func callDispatcher(callerLocationInfo: CallerLocationInfo) async throws -> (success: Bool, data: CallResult?) + func situationEnd(callId: Int) async throws -> (success: Bool, data: CallEndResult?) + func countDispatcher(callId: Int) async throws -> (success: Bool, data: DispatcherCountResult?) +} + +struct CallManager: CallService { + + private let service: Requestable + + init(service: Requestable) { + self.service = service + } + + func getCallerList() async throws -> (success: Bool, data: CallerListInfo?) { + let request = CallEndPoint + .getCallerList + .createRequest() + return try await self.service.request(request) + } + + func callDispatcher(callerLocationInfo: CallerLocationInfo) async throws -> (success: Bool, data: CallResult?) { + let request = CallEndPoint + .callDispatcher(callerLocationInfo: callerLocationInfo) + .createRequest() + return try await self.service.request(request) + } + + func situationEnd(callId: Int) async throws -> (success: Bool, data: CallEndResult?) { + let request = CallEndPoint + .situationEnd(callId: callId) + .createRequest() + return try await self.service.request(request) + } + + func countDispatcher(callId: Int) async throws -> (success: Bool, data: DispatcherCountResult?) { + let request = CallEndPoint + .countDispatcher(callId: callId) + .createRequest() + return try await self.service.request(request) + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Dispatch/DispatchEndPoint.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Dispatch/DispatchEndPoint.swift new file mode 100644 index 0000000..6494b67 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Dispatch/DispatchEndPoint.swift @@ -0,0 +1,65 @@ +// +// DispatchEndPoint.swift +// CPR2U +// +// Created by 황정현 on 2023/03/30. +// + +import Foundation + +enum DispatchEndPoint { + case dispatchAccept(cprCallId: Int) + case userReport(reportInfo: ReportInfo) + case dispatchEnd(dispatchId: Int) +} + +extension DispatchEndPoint: EndPoint { + + var method: HttpMethod { + switch self { + case .dispatchAccept, .userReport, .dispatchEnd: + return .POST + } + } + + var body: Data? { + var params: [String: Int] + switch self { + case .dispatchAccept(let cprCallId): + params = ["cpr_call_id": cprCallId] + case .userReport(let reportInfo): + return reportInfo.encode() + case .dispatchEnd(let dispatchId): + params = ["dispatch_id": dispatchId] + } + return params.encode() + } + + func getURL(path: String) -> String { + let baseURL = URLs.baseURL + switch self { + case .dispatchAccept: + return "\(baseURL)/dispatch" + case .userReport: + return "\(baseURL)/dispatch/report" + case .dispatchEnd(let dispatchId): + return "\(baseURL)/dispatch/arrive/\(dispatchId)" + } + } + + func createRequest() -> NetworkRequest { + let baseURL = URLs.baseURL + var headers: [String: String] = [:] + headers["Authorization"] = UserDefaultsManager.accessToken + switch self { + case .dispatchAccept: + headers["Content-Type"] = "application/json" + return NetworkRequest(url: getURL(path: baseURL), httpMethod: method, headers: headers, requestBody: body) + case .userReport: + headers["Content-Type"] = "application/json" + return NetworkRequest(url: getURL(path: baseURL), httpMethod: method, headers: headers, requestBody: body) + case .dispatchEnd: + return NetworkRequest(url: getURL(path: baseURL), httpMethod: method, headers: headers) + } + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Dispatch/DispatchManager.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Dispatch/DispatchManager.swift new file mode 100644 index 0000000..badc858 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Dispatch/DispatchManager.swift @@ -0,0 +1,44 @@ +// +// DispatchManager.swift +// CPR2U +// +// Created by 황정현 on 2023/03/30. +// + +import Foundation + +protocol DispatchService { + func dispatchAccept(cprCallId: Int) async throws -> (success: Bool, data: DispatchInfo?) + func userReport(reportInfo: ReportInfo) async throws -> (success: Bool, data: ReportResult?) + func dispatchEnd(dispatchId: Int) async throws -> (success: Bool, data: DispatchEndResult?) +} + +struct DispatchManager: DispatchService { + private let service: Requestable + + init(service: Requestable) { + self.service = service + } + + func dispatchAccept(cprCallId: Int) async throws -> (success: Bool, data: DispatchInfo?) { + let request = DispatchEndPoint + .dispatchAccept(cprCallId: cprCallId) + .createRequest() + return try await self.service.request(request) + } + + func userReport(reportInfo: ReportInfo) async throws -> (success: Bool, data: ReportResult?) { + let request = DispatchEndPoint + .userReport(reportInfo: reportInfo) + .createRequest() + return try await self.service.request(request) + } + + func dispatchEnd(dispatchId: Int) async throws -> (success: Bool, data: DispatchEndResult?) { + let request = DispatchEndPoint + .dispatchEnd(dispatchId: dispatchId) + .createRequest() + return try await self.service.request(request) + } +} + diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Dispatch/DispatchViewModel.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Dispatch/DispatchViewModel.swift new file mode 100644 index 0000000..80c683b --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Dispatch/DispatchViewModel.swift @@ -0,0 +1,16 @@ +// +// DispatchViewModel.swift +// CPR2U +// +// Created by 황정현 on 2023/03/31. +// + +import Foundation + +//class DispatchViewModel: OutputOnlyViewModelType { +// struct Output = { +// +// } +// +// +//} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Education/EducationEndPoint.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Education/EducationEndPoint.swift new file mode 100644 index 0000000..c1d0516 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Education/EducationEndPoint.swift @@ -0,0 +1,103 @@ +// +// EducationEndPoint.swift +// CPR2U +// +// Created by 황정현 on 2023/03/22. +// + +import Foundation + +enum EducationEndPoint { + case saveQuizResult(score: Int) + case saveLectureProgress(lectureId: Int) + case savePosturePracticeResult(score: Int) + case getEducationProgress + case getQuizList + case getLecture + case getPostureLecture +} + +extension EducationEndPoint: EndPoint { + + var method: HttpMethod { + switch self { + case .saveQuizResult, .saveLectureProgress, .savePosturePracticeResult: + return .POST + case .getEducationProgress, .getQuizList, .getLecture, .getPostureLecture: + return .GET + } + } + + var body: Data? { + var params: [String : Int] + switch self { + case .saveQuizResult(let score), .savePosturePracticeResult(let score): + params = ["score" : score] + case .saveLectureProgress, .getEducationProgress, .getQuizList, .getLecture, .getPostureLecture: + params = [:] + } + + return params.encode() + } + + func getURL(path: String) -> String { + let baseURL = URLs.baseURL + switch self { + case .saveQuizResult: + return "\(baseURL)/education/quizzes/progress" + case .saveLectureProgress(let lectureId): // ???? + return "\(baseURL)/education/lectures/progress/\(lectureId)" + case .savePosturePracticeResult: + return "\(baseURL)/education/exercises/progress" + case .getEducationProgress: + return "\(baseURL)/education" + case .getQuizList: + return "\(baseURL)/education/quizzes" + case .getLecture: + return "\(baseURL)/education/lectures" + case .getPostureLecture: + return "\(baseURL)/education/exercises" + } + } + + func createRequest() -> NetworkRequest { + let baseURL = URLs.baseURL + var headers: [String: String] = [:] + headers["Authorization"] = UserDefaultsManager.accessToken + + switch self { + case .saveQuizResult: + headers["Content-Type"] = "application/json" + return NetworkRequest(url: getURL(path: baseURL), + httpMethod: method, + headers: headers, + requestBody: body) + case .saveLectureProgress: + return NetworkRequest(url: getURL(path: baseURL), + httpMethod: method, + headers: headers) + case .savePosturePracticeResult: + headers["Content-Type"] = "application/json" + return NetworkRequest(url: getURL(path: baseURL), + httpMethod: method, + headers: headers, + requestBody: body) + case .getEducationProgress: + return NetworkRequest(url: getURL(path: baseURL), + httpMethod: method, + headers: headers) + case .getQuizList: + return NetworkRequest(url: getURL(path: baseURL), + httpMethod: method, + headers: headers) + case .getLecture: + return NetworkRequest(url: getURL(path: baseURL), + httpMethod: method, + headers: headers) + case .getPostureLecture: + return NetworkRequest(url: getURL(path: baseURL), + httpMethod: method, + headers: headers) + } + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Education/EducationManager.swift b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Education/EducationManager.swift new file mode 100644 index 0000000..e07b57a --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Service/Network/Education/EducationManager.swift @@ -0,0 +1,76 @@ +// +// EducationManager.swift +// CPR2U +// +// Created by 황정현 on 2023/03/22. +// + +import Foundation + +protocol EducationService { + func saveQuizResult(score: Int) async throws -> (success: Bool, data: QuizResult?) + func saveLectureProgress(lectureId: Int) async throws -> (success: Bool, data: LectureProgressResult?) + func savePosturePracticeResult(score: Int) async throws -> (success: Bool, data: PosturePracticeResult?) + func getEducationProgress() async throws -> (success: Bool, data: UserInfo?) + func getQuizList() async throws -> (success: Bool, data: [QuizInfo]?) + func getLecture() async throws -> (success: Bool, data: LectureProgressInfo?) + func getPostureLecture() async throws -> (success: Bool, data: PostureLectureInfo?) +} + +struct EducationManager: EducationService { + + private let service: Requestable + + init(service: Requestable) { + self.service = service + } + + func saveQuizResult(score: Int) async throws -> (success: Bool, data: QuizResult?) { + let request = EducationEndPoint + .saveQuizResult(score: score) + .createRequest() + return try await self.service.request(request) + } + + func saveLectureProgress(lectureId: Int) async throws -> (success: Bool, data: LectureProgressResult?) { + let request = EducationEndPoint + .saveLectureProgress(lectureId: lectureId) + .createRequest() + return try await self.service.request(request) + } + + func savePosturePracticeResult(score: Int) async throws -> (success: Bool, data: PosturePracticeResult?) { + let request = EducationEndPoint + .savePosturePracticeResult(score: score) + .createRequest() + return try await self.service.request(request) + } + + func getEducationProgress() async throws -> (success: Bool, data: UserInfo?) { + let request = EducationEndPoint + .getEducationProgress + .createRequest() + return try await self.service.request(request) + } + + func getQuizList() async throws -> (success: Bool, data: [QuizInfo]?) { + let request = EducationEndPoint + .getQuizList + .createRequest() + return try await self.service.request(request) + } + + func getLecture() async throws -> (success: Bool, data: LectureProgressInfo?) { + let request = EducationEndPoint + .getLecture + .createRequest() + return try await self.service.request(request) + } + + func getPostureLecture() async throws -> (success: Bool, data: PostureLectureInfo?) { + let request = EducationEndPoint + .getPostureLecture + .createRequest() + return try await self.service.request(request) + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Constants/Constraints.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Constants/Constraints.swift new file mode 100644 index 0000000..e3c1bda --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Constants/Constraints.swift @@ -0,0 +1,26 @@ +// +// Constraints.swift +// CPR2U +// +// Created by 황정현 on 2023/03/09. +// + +import Foundation + +final class Constraints { + static let shared = Constraints() + + let space2: CGFloat = 2 + let space4: CGFloat = 4 + let space6: CGFloat = 6 + let space8: CGFloat = 8 + let space10: CGFloat = 10 + let space12: CGFloat = 12 + let space14: CGFloat = 14 + let space16: CGFloat = 16 + let space18: CGFloat = 18 + let space20: CGFloat = 20 + let space24: CGFloat = 24 + + private init() { } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Constants/URLs.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Constants/URLs.swift new file mode 100644 index 0000000..5e7be8f --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Constants/URLs.swift @@ -0,0 +1,28 @@ +// +// URLs.swift +// CPR2U +// +// Created by 황정현 on 2023/03/11. +// + +import Foundation + +class URLs { +// FOR CI:CD +// static var baseURL: String = { +// return "" +// }() + + // REAL BaseURL + static var baseURL: String = { + guard let privatePlist = Bundle.main.url(forResource: "Private", withExtension: "plist"), let dictionary = NSDictionary(contentsOf: privatePlist), let link: String = dictionary["baseURL"] as? String else { return "" } + + return link + }() + + static var mapsAPIKey: String = { + guard let privatePlist = Bundle.main.url(forResource: "Private", withExtension: "plist"), let dictionary = NSDictionary(contentsOf: privatePlist), let link: String = dictionary["googleMapsAPIKey"] as? String else { return "" } + + return link + }() +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/Encodable+.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/Encodable+.swift new file mode 100644 index 0000000..8674fbd --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/Encodable+.swift @@ -0,0 +1,18 @@ +// +// Encodable+.swift +// CPR2U +// +// Created by 황정현 on 2023/03/12. +// + +import Foundation + +extension Encodable { + func encode() -> Data? { + do { + return try JSONEncoder().encode(self) + } catch { + return nil + } + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/Int+.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/Int+.swift new file mode 100644 index 0000000..e6c91c4 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/Int+.swift @@ -0,0 +1,32 @@ +// +// Int+.swift +// CPR2U +// +// Created by 황정현 on 2023/03/26. +// + +import Foundation + +extension Int { + func numberAsTime() -> String { + let mValue = self/60 + let sValue = self%60 + + let minuteStr = mValue < 10 ? "0\(mValue)" : "\(mValue)" + let secondStr = sValue < 10 ? "0\(sValue)" : "\(sValue)" + return "\(minuteStr):\(secondStr)" + } + + func numberAsExpirationDate() -> String { + let currentDate = Date() + var dateComponent = DateComponents() + dateComponent.day = self + guard let expirationDate = Calendar.current.date(byAdding: dateComponent, to: currentDate) else { return "" } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_kr") + formatter.timeZone = TimeZone(abbreviation: "KST") + formatter.dateFormat = "yyyy.MM.dd" + return formatter.string(from: expirationDate) + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIButton+.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIButton+.swift new file mode 100644 index 0000000..88d7dba --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIButton+.swift @@ -0,0 +1,16 @@ +// +// UIButton+.swift +// CPR2U +// +// Created by 황정현 on 2023/03/13. +// + +import UIKit + +extension UIButton { + func changeButtonStyle(isSelected: Bool) { + backgroundColor = isSelected ? UIColor.mainRed : UIColor.mainRed.withAlphaComponent(0.05) + setTitleColor(isSelected ? .mainWhite : .mainBlack, for: .normal) + } + +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIColor+.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIColor+.swift new file mode 100644 index 0000000..e572cc7 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIColor+.swift @@ -0,0 +1,34 @@ +// +// UIColor+.swift +// CPR2U +// +// Created by 황정현 on 2023/03/02. +// + +import UIKit + +extension UIColor { + + convenience init(rgb: Int) { + self.init( + red: CGFloat((rgb >> 16) & 0xFF) / 255.0, + green: CGFloat((rgb >> 8) & 0xFF) / 255.0, + blue: CGFloat(rgb & 0xFF) / 255.0, + alpha: 1 + ) + } + + static let mainWhite = UIColor(rgb: 0xFFF6F6) + + static let mainBlack = UIColor(rgb: 0x19191B) + static let mainDarkGray = UIColor(rgb: 0x595959) + static let mainLightGray = UIColor(rgb: 0xD9D9D9) + + static let mainDarkRed = UIColor(rgb: 0xB50000) + static let mainRed = UIColor(rgb: 0xF74346) + static let mainLightRed = UIColor(rgb: 0xFBA1A2) + + static let subOrange = UIColor(rgb: 0xFC7037) + static let subPink = UIColor(rgb: 0xFF0050) + +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIControl+.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIControl+.swift new file mode 100644 index 0000000..c90a40b --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIControl+.swift @@ -0,0 +1,56 @@ +// +// UIControl+.swift +// CPR2U +// +// Created by 황정현 on 2023/03/06. +// + +import UIKit +import Combine + +extension UIControl { + func controlPublisher(for event: UIControl.Event) -> UIControl.EventPublisher { + return UIControl.EventPublisher(control: self, event: event) + } + + // Publisher + struct EventPublisher: Publisher { + typealias Output = UIControl + typealias Failure = Never + + let control: UIControl + let event: UIControl.Event + + func receive(subscriber: S) where S : Subscriber, Never == S.Failure, UIControl == S.Input { + let subscription = EventSubscription(control: control, subscrier: subscriber, event: event) + subscriber.receive(subscription: subscription) + } + } + + // Subscription + fileprivate class EventSubscription: Subscription where EventSubscriber.Input == UIControl, EventSubscriber.Failure == Never { + + let control: UIControl + let event: UIControl.Event + var subscriber: EventSubscriber? + + init(control: UIControl, subscrier: EventSubscriber, event: UIControl.Event) { + self.control = control + self.subscriber = subscrier + self.event = event + + control.addTarget(self, action: #selector(eventDidOccur), for: event) + } + + func request(_ demand: Subscribers.Demand) {} + + func cancel() { + subscriber = nil + control.removeTarget(self, action: #selector(eventDidOccur), for: event) + } + + @objc func eventDidOccur() { + _ = subscriber?.receive(control) + } + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIFont+.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIFont+.swift new file mode 100644 index 0000000..1b92314 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIFont+.swift @@ -0,0 +1,25 @@ +// +// UIFont+.swift +// CPR2U +// +// Created by 황정현 on 2023/03/04. +// + +import UIKit + +enum FontWeight { + case bold, regular +} + +extension UIFont { + + convenience init?(weight: FontWeight, size: CGFloat) { + switch weight { + case .bold: + self.init(name: "NotoSans-Bold", size: size) + case .regular: + self.init(name: "NotoSans-Regular", size: size) + } + } + +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UITextField+.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UITextField+.swift new file mode 100644 index 0000000..b997a34 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UITextField+.swift @@ -0,0 +1,18 @@ +// +// UITextField+.swift +// CPR2U +// +// Created by 황정현 on 2023/03/06. +// + +import UIKit +import Combine + +extension UITextField { + var textPublisher: AnyPublisher { + controlPublisher(for: .editingChanged) + .map { $0 as! UITextField } + .map { $0.text! } + .eraseToAnyPublisher() + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIView+.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIView+.swift new file mode 100644 index 0000000..c11e1e3 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIView+.swift @@ -0,0 +1,71 @@ +// +// UIView+.swift +// CPR2U +// +// Created by 황정현 on 2023/03/21. +// + +import UIKit + +enum ToastMessage: Equatable { + case login(nickname: String) + case education + + func toastMessage() -> String { + switch self { + case .login(let nickname): + return "‘\(nickname)’ is Available" + case .education: + return "You must achieve 100% of your previous course progress." + } + } +} + extension UIView { + + func showToastMessage(type: ToastMessage) { + let width = UIScreen.main.bounds.width + let height = UIScreen.main.bounds.height + + let toastLabel = UILabel() + + let margin = 8 + + let labelYPos = type == .education ? Int(height * 0.78) : Int(height * 0.88) + let labelHeight = type == .education ? 80 : 45 + toastLabel.frame = CGRect(x: margin, y: labelYPos, width: Int(width) - margin * 2, height: labelHeight) + + toastLabel.font = UIFont(weight: .bold, size: 14) + toastLabel.text = type.toastMessage() + toastLabel.numberOfLines = 2 + toastLabel.textColor = .mainWhite + toastLabel.backgroundColor = .mainBlack + toastLabel.textAlignment = .center + toastLabel.layer.cornerRadius = 8 + toastLabel.clipsToBounds = true + toastLabel.isUserInteractionEnabled = false + toastLabel.layer.opacity = 0 + self.addSubview(toastLabel) + + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut, animations: { + toastLabel.layer.opacity = 1.0 + }, completion: {_ in + UIView.animate(withDuration: 0.5, delay: 0.8, options: .curveEaseOut, animations: { + toastLabel.layer.opacity = 0 + }, completion: {_ in + toastLabel.removeFromSuperview() + }) + }) + } + + func parentViewController() -> UIViewController { + var responder: UIResponder? = self + while !(responder is UIViewController) { + responder = responder?.next + if nil == responder { + break + } + } + return (responder as? UIViewController)! + } + + } diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIViewController+.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIViewController+.swift new file mode 100644 index 0000000..03ec65b --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIViewController+.swift @@ -0,0 +1,29 @@ +// +// UIViewController+.swift +// CPR2U +// +// Created by 황정현 on 2023/03/06. +// + +import UIKit + +extension UIViewController { + // https://stackoverflow.com/questions/24126678/close-ios-keyboard-by-touching-anywhere-using-swift + func hideKeyboardWhenTappedAround() { + let tap = UITapGestureRecognizer(target: self, action: #selector(UIViewController.dismissKeyboard)) + view.addGestureRecognizer(tap) + } + + @objc func dismissKeyboard() { + view.endEditing(true) + } + + func setUpOrientation(as status: UIInterfaceOrientationMask) { + UIApplication.shared.isIdleTimerDisabled = true + + if let delegate = UIApplication.shared.delegate as? AppDelegate { + delegate.orientationLock = status + } + self.setNeedsUpdateOfSupportedInterfaceOrientations() + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIWindow+.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIWindow+.swift new file mode 100644 index 0000000..8310e23 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Extension/UIWindow+.swift @@ -0,0 +1,31 @@ +// +// UIWindow+.swift +// CPR2U +// +// Created by 황정현 on 2023/03/22. +// + +import UIKit + +extension UIWindow { + + @MainActor + func setRootViewController(_ newRootViewController: UIViewController, animated: Bool = true) async { + guard animated else { + rootViewController = newRootViewController + return + } + + await withCheckedContinuation({ (continuation: CheckedContinuation) in + UIView.transition(with: self, duration: 0.3, options: .transitionCrossDissolve) { + let oldState: Bool = UIView.areAnimationsEnabled + UIView.setAnimationsEnabled(false) + self.rootViewController = newRootViewController + UIView.setAnimationsEnabled(oldState) + } completion: { _ in + continuation.resume() + } + }) + } + +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Protocol/ViewModelProtocol.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Protocol/ViewModelProtocol.swift new file mode 100644 index 0000000..b2ed110 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Protocol/ViewModelProtocol.swift @@ -0,0 +1,29 @@ +// +// ViewModelProtocol.swift +// CPR2U +// +// Created by 황정현 on 2023/03/20. +// + +import Foundation + +protocol DefaultViewModelType { + associatedtype Input + associatedtype Output + + func transform(input: Input) -> Output +} + + +protocol AsyncOutputOnlyViewModelType { + associatedtype Output + + func transform() async throws -> Output +} + + +protocol OutputOnlyViewModelType { + associatedtype Output + + func transform() -> Output +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Shared/DeviceTokenManager.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Shared/DeviceTokenManager.swift new file mode 100644 index 0000000..fa4e596 --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Shared/DeviceTokenManager.swift @@ -0,0 +1,14 @@ +// +// DeviceTokenManager.swift +// CPR2U +// +// Created by 황정현 on 2023/03/22. +// + +import Foundation + +final class DeviceTokenManager { + static var deviceToken: String = "" + + private init() { } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Shared/MapManager.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Shared/MapManager.swift new file mode 100644 index 0000000..e65655d --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Shared/MapManager.swift @@ -0,0 +1,23 @@ +// +// MapManager.swift +// CPR2U +// +// Created by 황정현 on 2023/03/26. +// + +import Foundation +import GoogleMaps +import GooglePlaces + +final class MapManager { + func setLocation() -> CLLocationCoordinate2D { + let locationManager = CLLocationManager() + locationManager.requestWhenInUseAuthorization() + locationManager.desiredAccuracy = kCLLocationAccuracyBest + locationManager.startUpdatingLocation() + + guard let coor = locationManager.location?.coordinate else { return CLLocationCoordinate2D(latitude: 59, longitude: 59)} + + return coor + } +} diff --git a/CPR2U-iOS/CPR2U/CPR2U/Util/Wrapper/UserDefaultsManager.swift b/CPR2U-iOS/CPR2U/CPR2U/Util/Wrapper/UserDefaultsManager.swift new file mode 100644 index 0000000..506b0ef --- /dev/null +++ b/CPR2U-iOS/CPR2U/CPR2U/Util/Wrapper/UserDefaultsManager.swift @@ -0,0 +1,42 @@ +// +// UserDefaultsManager.swift +// CPR2U +// +// Created by 황정현 on 2023/03/21. +// + +import Foundation + +@propertyWrapper +fileprivate struct UserDefaultWrapper { + private let key: String + private let defaultValue: E + + init(key: String, defaultValue: E) { + self.key = key + self.defaultValue = defaultValue + } + + var wrappedValue: E { + get { + return UserDefaults.standard.object(forKey: key) as? E ?? defaultValue + } + set { + UserDefaults.standard.set(newValue, forKey: key) + } + } +} + +struct UserDefaultsManager { + @UserDefaultWrapper(key: "accessToken", defaultValue: "") + static var accessToken + + @UserDefaultWrapper(key: "refreshToken", defaultValue: "") + static var refreshToken + + @UserDefaultWrapper(key: "isCertificateNotice", defaultValue: false) + static var isCertificateNotice + + @UserDefaultWrapper(key: "isAddressSet", defaultValue: false) + static var isAddressSet +} diff --git a/CPR2U-iOS/CPR2U/Podfile b/CPR2U-iOS/CPR2U/Podfile new file mode 100644 index 0000000..0d6fc8e --- /dev/null +++ b/CPR2U-iOS/CPR2U/Podfile @@ -0,0 +1,16 @@ +# Uncomment the next line to define a global platform for your project +# platform :ios, '9.0' + +target 'CPR2U' do + # Comment the next line if you don't want to use dynamic frameworks + use_frameworks! + + # Pods for CPR2U +use_frameworks! + +pod 'TensorFlowLiteSwift', '~> 0.0.1-nightly', :subspecs => ['CoreML', 'Metal'] +source 'https://github.com/CocoaPods/Specs.git' +pod 'GoogleMaps' +pod 'GooglePlaces' + +end diff --git a/CPR2U-iOS/CPR2U/Podfile.lock b/CPR2U-iOS/CPR2U/Podfile.lock new file mode 100644 index 0000000..5b85947 --- /dev/null +++ b/CPR2U-iOS/CPR2U/Podfile.lock @@ -0,0 +1,45 @@ +PODS: + - GoogleMaps (7.4.0): + - GoogleMaps/Maps (= 7.4.0) + - GoogleMaps/Base (7.4.0) + - GoogleMaps/Maps (7.4.0): + - GoogleMaps/Base + - GooglePlaces (7.4.0) + - TensorFlowLiteC (0.0.1-nightly.20230323): + - TensorFlowLiteC/Core (= 0.0.1-nightly.20230323) + - TensorFlowLiteC/Core (0.0.1-nightly.20230323) + - TensorFlowLiteC/CoreML (0.0.1-nightly.20230323): + - TensorFlowLiteC/Core + - TensorFlowLiteC/Metal (0.0.1-nightly.20230323): + - TensorFlowLiteC/Core + - TensorFlowLiteSwift/Core (0.0.1-nightly.20230323): + - TensorFlowLiteC (= 0.0.1-nightly.20230323) + - TensorFlowLiteSwift/CoreML (0.0.1-nightly.20230323): + - TensorFlowLiteC/CoreML (= 0.0.1-nightly.20230323) + - TensorFlowLiteSwift/Core (= 0.0.1-nightly.20230323) + - TensorFlowLiteSwift/Metal (0.0.1-nightly.20230323): + - TensorFlowLiteC/Metal (= 0.0.1-nightly.20230323) + - TensorFlowLiteSwift/Core (= 0.0.1-nightly.20230323) + +DEPENDENCIES: + - GoogleMaps + - GooglePlaces + - TensorFlowLiteSwift/CoreML (~> 0.0.1-nightly) + - TensorFlowLiteSwift/Metal (~> 0.0.1-nightly) + +SPEC REPOS: + https://github.com/CocoaPods/Specs.git: + - GoogleMaps + - GooglePlaces + - TensorFlowLiteC + - TensorFlowLiteSwift + +SPEC CHECKSUMS: + GoogleMaps: 032f676450ba0779bd8ce16840690915f84e57ac + GooglePlaces: 544e908d94860bf6f8fc1865b80d2c7eb6b5f937 + TensorFlowLiteC: 1b9920491580a9347af1916cefd65bf3a2fd4e5d + TensorFlowLiteSwift: 6459fdef64514c95a6675a17c2898c3424bc73b0 + +PODFILE CHECKSUM: 663cb07e0f0ad41b9fa22176a997112fde89d754 + +COCOAPODS: 1.11.3 diff --git a/CPR2U-iOS/README.md b/CPR2U-iOS/README.md new file mode 100644 index 0000000..02ec0f0 --- /dev/null +++ b/CPR2U-iOS/README.md @@ -0,0 +1,2 @@ +# CPR2U-iOS +CPR2U iOS diff --git a/CPR2U-iOS/license.txt b/CPR2U-iOS/license.txt new file mode 100644 index 0000000..67cc163 --- /dev/null +++ b/CPR2U-iOS/license.txt @@ -0,0 +1,203 @@ +Copyright 2018 The TensorFlow Authors. All rights reserved. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017, The TensorFlow Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/CPR2U-iOS/pull_request_template.md b/CPR2U-iOS/pull_request_template.md new file mode 100644 index 0000000..e6e5145 --- /dev/null +++ b/CPR2U-iOS/pull_request_template.md @@ -0,0 +1,9 @@ +### One line Description +- `Work Description` + +### Work Detail(Optional) +**필요 시 스크린샷 OR 영상 첨부** + +### Issue +- #IssueNumber +- Close #IssueNumber \ No newline at end of file